From 9f9f60bbc06098b18a176d487257c8010d14e9b6 Mon Sep 17 00:00:00 2001 From: Preet Vadaliya Date: Tue, 8 Sep 2026 19:46:34 +0200 Subject: [PATCH 01/20] feat: add blockly-workspace-notes plugin --- .github/workflows/test-workspace-notes.yml | 70 + README.md | 1 + blockly-workspace-notes/.prettierignore | 5 + blockly-workspace-notes/.prettierrc.json | 5 + blockly-workspace-notes/DESIGN.md | 399 + blockly-workspace-notes/README.md | 89 + .../docs/images/note-anatomy.svg | 47 + .../docs/images/note-states.svg | 72 + blockly-workspace-notes/eslint.config.mjs | 116 + blockly-workspace-notes/package-lock.json | 8514 +++++++++++++++++ blockly-workspace-notes/package.json | 84 + blockly-workspace-notes/src/colour.ts | 79 + blockly-workspace-notes/src/constants.ts | 206 + blockly-workspace-notes/src/context_menu.ts | 328 + blockly-workspace-notes/src/css.ts | 311 + blockly-workspace-notes/src/events.ts | 147 + blockly-workspace-notes/src/index.ts | 230 + blockly-workspace-notes/src/note.ts | 724 ++ blockly-workspace-notes/src/paster.ts | 140 + blockly-workspace-notes/src/serializer.ts | 407 + blockly-workspace-notes/src/title_editor.ts | 171 + blockly-workspace-notes/src/types.ts | 133 + blockly-workspace-notes/src/xml.ts | 325 + blockly-workspace-notes/test/colour.mocha.js | 94 + blockly-workspace-notes/test/index.html | 18 + blockly-workspace-notes/test/index.js | 133 + blockly-workspace-notes/test/note.mocha.js | 175 + .../test/serializer.mocha.js | 409 + blockly-workspace-notes/test/xml.mocha.js | 417 + blockly-workspace-notes/tsconfig.json | 33 + 30 files changed, 13882 insertions(+) create mode 100644 .github/workflows/test-workspace-notes.yml create mode 100644 blockly-workspace-notes/.prettierignore create mode 100644 blockly-workspace-notes/.prettierrc.json create mode 100644 blockly-workspace-notes/DESIGN.md create mode 100644 blockly-workspace-notes/README.md create mode 100644 blockly-workspace-notes/docs/images/note-anatomy.svg create mode 100644 blockly-workspace-notes/docs/images/note-states.svg create mode 100644 blockly-workspace-notes/eslint.config.mjs create mode 100644 blockly-workspace-notes/package-lock.json create mode 100644 blockly-workspace-notes/package.json create mode 100644 blockly-workspace-notes/src/colour.ts create mode 100644 blockly-workspace-notes/src/constants.ts create mode 100644 blockly-workspace-notes/src/context_menu.ts create mode 100644 blockly-workspace-notes/src/css.ts create mode 100644 blockly-workspace-notes/src/events.ts create mode 100644 blockly-workspace-notes/src/index.ts create mode 100644 blockly-workspace-notes/src/note.ts create mode 100644 blockly-workspace-notes/src/paster.ts create mode 100644 blockly-workspace-notes/src/serializer.ts create mode 100644 blockly-workspace-notes/src/title_editor.ts create mode 100644 blockly-workspace-notes/src/types.ts create mode 100644 blockly-workspace-notes/src/xml.ts create mode 100644 blockly-workspace-notes/test/colour.mocha.js create mode 100644 blockly-workspace-notes/test/index.html create mode 100644 blockly-workspace-notes/test/index.js create mode 100644 blockly-workspace-notes/test/note.mocha.js create mode 100644 blockly-workspace-notes/test/serializer.mocha.js create mode 100644 blockly-workspace-notes/test/xml.mocha.js create mode 100644 blockly-workspace-notes/tsconfig.json diff --git a/.github/workflows/test-workspace-notes.yml b/.github/workflows/test-workspace-notes.yml new file mode 100644 index 0000000..b6c5c9a --- /dev/null +++ b/.github/workflows/test-workspace-notes.yml @@ -0,0 +1,70 @@ +name: Test blockly-workspace-notes + +on: + pull_request: + workflow_dispatch: + +jobs: + quality: + name: Lint and format + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + working-directory: blockly-workspace-notes + run: npm ci + + - name: Lint + working-directory: blockly-workspace-notes + run: npm run lint + + - name: Check formatting + working-directory: blockly-workspace-notes + run: npm run format:check + + # `build:types`, not `typecheck`: `tsc --noEmit` returns before + # declaration diagnostics run, so it cannot see the TS4xxx errors that + # declaration emit surfaces. + - name: Typecheck + working-directory: blockly-workspace-notes + run: npm run build:types + + test-workspace-notes: + name: Test on Node ${{ matrix.node }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # The minimum supported version from package.json engines, plus the + # current LTS releases. Node 18 is excluded deliberately: it is past + # end of life, and jsdom (which blockly/core-node.js pulls in for XML + # handling) now reaches an ES module through require(), which only + # works from Node 20.19 onwards. + node: [20, 22, 24] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + + - name: Install dependencies + working-directory: blockly-workspace-notes + run: npm ci + + - name: Run blockly-workspace-notes tests + working-directory: blockly-workspace-notes + run: npm run test + + - name: Build + working-directory: blockly-workspace-notes + run: npm run build diff --git a/README.md b/README.md index a7ebc88..dec3576 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,4 @@ Plugins for [Blockly](https://github.com/google/blockly) based on code by [MIT App Inventor](https://github.com/mit-cml/appinventor-sources) * [Lexical Variables](./block-lexical-variables) +* [Workspace Notes](./blockly-workspace-notes) diff --git a/blockly-workspace-notes/.prettierignore b/blockly-workspace-notes/.prettierignore new file mode 100644 index 0000000..2630d31 --- /dev/null +++ b/blockly-workspace-notes/.prettierignore @@ -0,0 +1,5 @@ +build/ +dist/ +node_modules/ +package-lock.json +docs/images/ diff --git a/blockly-workspace-notes/.prettierrc.json b/blockly-workspace-notes/.prettierrc.json new file mode 100644 index 0000000..0e56068 --- /dev/null +++ b/blockly-workspace-notes/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "bracketSpacing": false, + "singleQuote": true, + "quoteProps": "preserve" +} diff --git a/blockly-workspace-notes/DESIGN.md b/blockly-workspace-notes/DESIGN.md new file mode 100644 index 0000000..05e42a2 --- /dev/null +++ b/blockly-workspace-notes/DESIGN.md @@ -0,0 +1,399 @@ +# 📝 Workspace Notes — Design Document + +**Product:** Sticky notes for a Blockly workspace +**Status:** Built and working +**Last updated:** 7 September 2026 + +--- + +## 1. đŸŽ¯ The problem + +A Blockly workspace is full of blocks, and blocks only say what the program +does. They cannot say _why_. + +People need somewhere to put the human part: a reminder, a question for a +teammate, a warning about a tricky bit, a to-do list for later. Today there is +nowhere good to put that. + +**Workspace Notes** adds sticky notes you can place anywhere on the canvas. + +The single most important requirement: **a note must survive a save and +reload.** A note that disappears when you close the tab is worse than useless, +because people will trust it and then lose work. + +--- + +## 2. 👤 Who it is for + +| Person | What they need | How notes help | +| ----------------- | --------------------------------------- | ---------------------------------------------------- | +| 🎓 **A teacher** | Leave instructions on a starter project | Pin a bright note at the top with the task | +| 🧑‍🎓 **A student** | Remember what they were doing | Drop a note next to the half-finished part | +| đŸ‘Ĩ **A team** | Explain a decision to each other | A titled, colour-coded note beside the tricky blocks | +| 🧑‍đŸ’ģ **A reviewer** | Flag things without changing code | A red-ish note saying "this loop looks wrong" | + +--- + +## 3. ✅ Goals and đŸšĢ Non-goals + +### Goals + +- ✅ Notes save and load with the workspace, every time, losing nothing. +- ✅ Notes feel like part of Blockly, not something bolted on. +- ✅ Notes are quick to create and quick to get out of the way. +- ✅ A note can be told apart at a glance — by colour and by title. +- ✅ Everything is undoable. Nothing is lost by accident. +- ✅ Old files that had plain comments still open, and keep working. + +### Non-goals + +- đŸšĢ **Rich text.** No bold, links, or images inside a note. Plain text only. +- đŸšĢ **Comment threads.** A note is not a discussion. No replies, no mentions. +- đŸšĢ **Live collaboration.** Two people editing the same note at once is out of + scope. +- đŸšĢ **Notes tied to a block.** A note lives on the canvas, not attached to a + block that might move or be deleted. +- đŸšĢ **Notes fixed to the screen.** A note lives on the canvas and scrolls with + it. See the decision in section 9. + +--- + +## 4. 🧭 Scope: what is new, and what we get for free + +Blockly already has "workspace comments" — a plain box you can type into. It +turns out they already do a lot. + +| Capability | Already in Blockly | New in this design | +| ------------------------------------------------- | ------------------ | ------------------ | +| A box on the canvas you can type into | ✅ | | +| Drag to move | ✅ | | +| Drag a corner to resize | ✅ | | +| Collapse and expand | ✅ | | +| Delete | ✅ | | +| Copy, paste, duplicate | ✅ | | +| Keyboard navigation and screen-reader labels | ✅ | | +| Undo and redo | ✅ | | +| A **title** | | ✨ New | +| A **colour** per note | | ✨ New | +| **Pinning** — lock a note in place | | ✨ New | +| **Stacking order** — bring to front, send to back | | ✨ New | +| **Author and dates** recorded automatically | | ✨ New | +| A **versioned save format** for all of the above | | ✨ New | + +> 💡 **The design decision behind this table:** we build _on top of_ Blockly's +> comment rather than replacing it. Everything in the left column keeps working +> exactly as people already expect, and the new work is only the right column. + +--- + +## 5. đŸ–ŧī¸ What a note looks like + +### The parts of a note + +![Anatomy of a note](docs/images/note-anatomy.svg) + +A note is one sheet of paper: + +- **The title row** — a heading printed on the paper itself, set a size above + the body and in bold, because a heading at body size is not a heading. No + strip, no buttons; the title is the only thing there. +- **A hairline rule** under it, dividing the heading from the body. +- **The body** — the text, written straight onto the paper. No box around it. + +### The states a note can be in + +![Note states](docs/images/note-states.svg) + +The top-left one is a plain Blockly comment, shown for comparison. Square +corners, a darker header strip, text in a bordered box. Both halves of that are +block grammar: a rectangle with a strip across the top is a block's silhouette, +and a lighter bordered box inset into a coloured body is how a field on a block +is drawn. A note drops both — a rounded card, a heading, a rule, and the text on +the paper. + +### Visual rules + +| Rule | Why | +| ------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| 📐 Even spacing on every side | Uneven gaps look accidental. Every margin around an icon or the text is the same. | +| 🎨 Edge colour follows the fill | Each colour gets a matching, slightly deeper edge, so notes look like a set rather than seven unrelated stickers. | +| 🔤 Title always readable | The title is dark, bold and a size larger than the body on every colour in the palette. | +| đŸ¤Ģ No buttons at all | Every action is in the context menu, so nothing sits on the paper but the words on it. | +| âœ‚ī¸ Long titles shorten | A title too long for the note is cut with an ellipsis, and re-cut live while the note is resized. | + +--- + +## 6. ✨ The features + +### 6.1 Create a note + +Right-click anywhere on empty canvas and choose **Add Comment**. The note +appears where you clicked, ready to type in. + +### 6.2 Write in it + +Click the body and type. An empty note shows faded placeholder text so it never +looks broken. Text is saved as you go. + +### 6.3 Move and resize + +Drag the note by its title row to move it. Drag the bottom-right corner to +resize; the title re-shortens as you go, so it never runs off the paper. The +drag stops at a floor of one title row, one line of body and the margin under +it, so a note can always be seen and grabbed — dragging one down to nothing +would leave a note that is still on the workspace and still saved, findable +only by undo. + +### 6.4 Give it a title đŸˇī¸ + +Click the title and type — the same as clicking into the body below it. +Nothing opens and nothing moves; the heading just gains a caret. Enter commits, +Escape puts the old title back, and clicking elsewhere commits. A note that has +not been named shows a greyed `Title`, which stays put while you type over it. + +Dragging a note by its title still drags it — the editor only opens if the +press stayed put, which is the same test Blockly uses to tell a field click +from a drag. + +The title is the thing you read when you are scanning a busy workspace, so it +stays visible even when the note is collapsed down to a single strip. + +### 6.5 Colour it 🎨 + +Right-click the note and pick from a row of seven colours: yellow, orange, +pink, purple, blue, green, grey. + +The swatches sit in a single row inside the menu rather than as seven separate +menu entries, so choosing a colour is one click and the menu stays short. The +current colour is ringed so you can see what the note is now. + +Colours are pale on purpose. A note has to be readable, and it must not shout +louder than the blocks around it. + +### 6.6 Pin it 📌 + +Right-click → **Pin note**. A pinned note: + +- 🔒 **Cannot be dragged.** It stays exactly where you put it. +- âŦ†ī¸ **Sits in front** of other notes. +- âœī¸ **Draws a heavier edge**, so you can see why it will not move. + +Right-click → **Unpin note** to release it. This is for the note that must not +be nudged out of the way — a teacher's instructions, a warning at the top of a +file. + +### 6.7 Order them đŸ”ŧ + +Right-click → **Bring to front** or **Send to back**. Useful when notes overlap. +The order is remembered when you save. + +### 6.8 Collapse it đŸ”Ŋ + +Right-click → **Collapse note** to fold a note down to just its title row. The +title stays visible. Right-click → **Expand note** to open it again. + +This is how you keep a long note around without it covering your blocks. + +### 6.9 Duplicate it 📋 + +Right-click → **Duplicate note**, or copy and paste. The copy keeps the title, the +colour, and the pinned state, and lands slightly offset so it does not hide the +original. + +### 6.10 Delete it, and undo đŸ—‘ī¸ â†Šī¸ + +Right-click → **Delete note**, or press Delete while it is selected. + +**One press of undo brings the note back complete** — same text, same title, +same colour, same pinned state. Not a blank note that you then have to +re-decorate. This mattered enough to design for specifically. + +Every change is undoable: typing, resizing, recolouring, renaming, pinning, +reordering. + +--- + +## 7. 💾 What gets remembered + +This is the headline feature, so it is worth being explicit about it. + +When a workspace is saved, every note is saved alongside the blocks. Nothing +extra to do, nothing separate to call. + +| Remembered | Notes | +| ------------------------------------- | --------------------------------------------------- | +| 📍 Position on the canvas | Exact, including right-to-left layouts | +| 📏 Width and height | Exactly as the user left it | +| 📝 The text | | +| đŸˇī¸ The title | Only if it has one | +| 🎨 The colour | Only if it is not the default | +| 📌 Pinned or not | | +| đŸ”ŧ Stacking order | So overlapping notes come back in the same order | +| đŸ”Ŋ Collapsed or expanded | | +| 👤 Author | Whoever created it, if the host app supplies a name | +| 🕐 Created date and last-changed date | Set automatically | + +A saved note looks like this: + + { + "id": "n1qX", + "x": 40, "y": 20, + "width": 240, "height": 140, + "text": "Refactor this loop", + "title": "TODO", + "colour": "#ffd6a5", + "meta": { + "author": "ada", + "createdAt": "2026-09-07T16:07:09Z", + "updatedAt": "2026-09-07T16:09:41Z" + } + } + +### Three rules about the format + +**đŸ“Ļ Only write what is different.** A plain yellow note with no title records +no colour and no title. Files stay small, and a change to one note shows up as +a small, readable difference rather than a wall of text. + +**đŸ”ĸ Always record a version number.** The saved data carries a version. If the +format ever gains a field or changes shape, files saved today can still be +opened tomorrow — they are quietly upgraded as they load. This costs almost +nothing now and avoids a painful migration later. + +**đŸ•°ī¸ Old files still open.** Workspaces saved before this feature existed, with +plain Blockly comments in them, load correctly. Each old comment becomes a note +with default colour and no title. Nothing is lost, and nothing needs converting +by hand. + +--- + +## 8. 🔄 Behaviour rules + +These are the small decisions that make the feature feel finished. + +| Situation | What happens | +| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| A note is saved and the file is loaded twice | You get the same notes back. Never duplicates. | +| A note is pinned, then you try to drag it | Nothing moves. The heavier edge explains why. | +| A note is pinned, then unpinned | It becomes draggable again. | +| A title is longer than the note is wide | It is shortened with an ellipsis, live, as the note is resized. | +| The writing is longer than the note is tall | The body scrolls. The bar is thin, trackless and in the paper's own edge colour, painted only while the pointer is on the note or the caret is in it. | +| A note is resized down as far as it will go | It stops at its minimum, still readable and still grabbable. It cannot be hidden. | +| A note is collapsed and has a title | The title shows. | +| A note is collapsed and has no title | The greyed `Title` placeholder shows, as it does when expanded. | +| A note is deleted and undone | It returns complete, in one undo. | +| A note is duplicated | The copy keeps everything and is offset so both are visible. | +| The workspace is read-only | Notes can be read and moved through, but not edited. | +| A file is loaded | Loading does not fill up the undo history. Undo still means "undo what _I_ did". | + +--- + +## 9. âš–ī¸ Design decisions + +### Why extend Blockly's comment instead of building a new object + +Blockly's comment already handles dragging, resizing, keyboard access, screen +readers, copy and paste, and undo. Rebuilding all of that would have been a +large amount of work whose only outcome is re-creating bugs that are already +fixed. + +**Trade-off:** the design is tied to how Blockly's comments behave. If Blockly +changes them, this feature has to follow. + +### Why "pinned" means locked, not fixed to the screen + +"Pinned" could mean two things: + +1. 🔒 **Locked in place** — it stays where you put it on the canvas. +2. 📌 **Fixed to the screen** — it never scrolls away, like a heads-up display. + +We chose **locked in place**. The screen-fixed version fights the way a +workspace scrolls and zooms, and a note that floats over your blocks wherever +you pan is more annoying than helpful. + +**Open question:** if the screen-fixed behaviour turns out to be what teachers +actually want for instructions, that is a change of behaviour rather than a +bug, and worth revisiting. + +### Why the title is edited in place rather than in a dialog + +An earlier version asked for the title in a dialog, which was the wrong +instinct twice over: Blockly never asks for text in a dialog, and the fallback +`window.prompt` is a serif system box that matches nothing else on the page. +Clicking the title and typing is what a field on a block already does, so it is +the behaviour people arrive with. + +Replacing it with Blockly's field editor was only half the fix, though, because +a field editor is built to be seen — a white box with the text selected. On a +note that still read as a mode opening on the paper. The editor is now +invisible: same position, same font, same placeholder, no box, no selection. +The rule is that the title should behave exactly like the body, and the body +has never needed anything to open. + +**Trade-off:** a click on the title now does two things depending on whether it +moved. Blockly's own gesture code draws that line at the drag radius, and this +follows it, so dragging a note by its title still works. + +### Why a note has no buttons + +Every earlier round put icons in the top bar — collapse, delete, pin — and every +round the note read as a block, because a strip of icons across the top of a +rectangle is exactly what a block looks like. Moving all of it into the context +menu leaves nothing on the paper but the words on it, and the menu is where +people look for actions on a right-clickable object anyway. + +**Trade-off:** collapsing and pinning are one click further away, and slightly +less discoverable. Pinning was already menu-only; collapsing is the real cost. + +### Why notes get their own place in the save file + +Notes are saved separately from Blockly's plain comments rather than pretending +to be them. This keeps the extra information clean and versioned, and means a +note is never saved twice by accident. + +**Trade-off:** a file saved with notes is not a plain Blockly file. Anything +reading it needs to know about notes — though a plain Blockly editor would +simply ignore them rather than break. + +### Why colours are pale + +Notes sit among coloured blocks. A saturated note would compete with them and +make the workspace harder to read. Pale fills with a slightly deeper edge read +as "paper on a desk" rather than "another block". + +Colour alone was not enough, though — several rounds of tuning hue, icon colour +and text colour all failed, because what reads as a block is the shape. The +palette was pulled paler again once the shape was fixed, so the two work +together: S 0.25 at V 0.98, against a block's S 0.45 at V 0.65. + +--- + +## 10. 🚧 Limits and future work + +### Known limits + +- 📄 **Plain text only.** No formatting, links or images in a note. +- 🎨 **Seven colours.** No custom colour picker. +- 🔍 **Not searchable.** There is no way to find a note by its text yet. +- 🔗 **Not attached to blocks.** A note near a block is only near it by + position. Move the block and the note stays put. + +### Ideas worth considering next + +| Idea | Why it might matter | +| ------------------------------- | ----------------------------------------------------------- | +| 🔍 **Search notes** | Once a workspace has twenty notes, finding one is hard. | +| 🔗 **Attach a note to a block** | The most-requested thing this design deliberately left out. | +| ✅ **Checklists** | Teachers writing task lists would use them immediately. | +| đŸˇī¸ **Colour meanings** | Let a project define "red = bug, green = done". | +| 👤 **Show the author** | The name is already recorded but never displayed. | + +--- + +## 11. ❓ Open questions + +1. Should pinned notes stay fixed on screen instead of on the canvas? + (See section 9.) +2. Should the author's name be visible on the note, or stay hidden data? +3. Are seven colours enough, or is a custom colour needed? +4. Should a note be able to point at a block, without being owned by it? diff --git a/blockly-workspace-notes/README.md b/blockly-workspace-notes/README.md new file mode 100644 index 0000000..242d00f --- /dev/null +++ b/blockly-workspace-notes/README.md @@ -0,0 +1,89 @@ +

+ blockly-workspace-notes +
+ TypeScript + Node.js + NPM +
+ Blockly + License +

+ +**blockly-workspace-notes** turns Blockly's workspace comments into sticky notes: draggable, resizable, colour-coded paper with a title, an author and a stacking order. They extend Blockly's own comments, so dragging, selection, keyboard navigation and undo all come for free, and they round-trip through both JSON and XML. + +

+ The parts of a note +

+ +## Core Dependencies + +Before setting up the project, ensure you have the following installed: + +1. **Node.js** — `v20+`   [Download Node.js](https://nodejs.org/) +2. **NPM** — `v10+`   [Learn about NPM](https://www.npmjs.com/) +3. **Blockly** — `v13.2.1` (peer dependency)   [Blockly docs](https://developers.google.com/blockly) + +> [!IMPORTANT] +> **Import the plugin before `Blockly.inject`.** +> +> `Blockly.Css.register` only affects injections that happen after it runs, so a +> note imported later renders unstyled. Notes are saved under their own +> versioned `workspaceNotes` key alongside `blocks`; older files written under +> `workspaceComments` still load. + +## Repository Structure + +```plaintext +| +├── 📁 .github # CI and publish workflows +├── 📁 docs # README media +├── 📁 src # Plugin source, TypeScript +├── 📁 test # Mocha suites and the playground +├── 📄 .gitignore +├── 📄 .prettierrc.json # Format config +├── 📄 eslint.config.mjs # Lint config +├── 📄 tsconfig.json # TypeScript config +├── 📄 DESIGN.md # Design decisions and rationale +├── 📄 LICENSE +├── 📄 package.json +└── 📄 README.md # Project overview +| +``` + +## Getting Started + +### Use it in an application + +```bash +npm install @mit-app-inventor/blockly-workspace-notes +``` + +```js +import * as Blockly from 'blockly'; +import {WorkspaceNotes} from '@mit-app-inventor/blockly-workspace-notes'; + +const workspace = Blockly.inject('blocklyDiv', {toolbox}); +new WorkspaceNotes(workspace).init(); +``` + +Right-click the workspace to add a note; right-click a note to rename, recolour, pin or restack it. + +### Work on it locally + +```bash +git clone https://github.com/mit-cml/blockly-plugins.git +cd blockly-plugins/blockly-workspace-notes +npm install +npm start +``` + +`npm start` opens the playground. Other scripts: + +```bash +npm test # typecheck, then the mocha suites +npm run build # bundle and .d.ts into dist/ +npm run lint # ESLint +npm run format # Prettier +``` + +

Built with :heart: for Blockly

diff --git a/blockly-workspace-notes/docs/images/note-anatomy.svg b/blockly-workspace-notes/docs/images/note-anatomy.svg new file mode 100644 index 0000000..e2df9ca --- /dev/null +++ b/blockly-workspace-notes/docs/images/note-anatomy.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + Title + click it and type + Rule + equal air above and below + Rounded card + pale fill, hairline edge + The body + written onto the paper, no box + Resize handle + + + + + + Shopping list + + + milk + bread + coffee + + + + + + + + + + + + + + margin 16, the same on every side + diff --git a/blockly-workspace-notes/docs/images/note-states.svg b/blockly-workspace-notes/docs/images/note-states.svg new file mode 100644 index 0000000..1b94e28 --- /dev/null +++ b/blockly-workspace-notes/docs/images/note-states.svg @@ -0,0 +1,72 @@ + + + + + A plain Blockly comment + A note + Not named yet + Collapsed + Pinned + Selected + + + header strip, text in a box + rounded, heading on the paper + greyed placeholder + the title row is the whole note + locked in place, heavier edge + the ring follows the card + + + + + + + + + Refactor this loop + + + + + + Shopping list + + + milk + bread + coffee + + + + + + + Title + + Say something... + + + + + + Release checklist + + + + + + Do not move + + Locked in place. + + + + + + + Ideas + + Try a smaller step. + + diff --git a/blockly-workspace-notes/eslint.config.mjs b/blockly-workspace-notes/eslint.config.mjs new file mode 100644 index 0000000..bcb51d5 --- /dev/null +++ b/blockly-workspace-notes/eslint.config.mjs @@ -0,0 +1,116 @@ +/** + * @fileoverview ESLint configuration. + * + * Blockly publishes `@blockly/eslint-config`, but it still peers on ESLint 7 + * and pulls in the deprecated `babel-eslint`, so this reproduces the house + * style it encodes — Google-ish source with mandatory documentation — on a + * current toolchain instead. + * + * The source is TypeScript and the tests are JavaScript, which is not a + * stylistic choice: `@blockly/dev-scripts` finds test entry points with a + * literal `.mocha.js` filename filter, so a `.mocha.ts` suite is silently + * skipped and `npm test` passes having run nothing. The two `files` blocks + * below reflect that split. + * + * Formatting rules are left entirely to Prettier: `eslint-config-prettier` + * goes last and switches off everything the two would otherwise argue about. + */ + +import js from '@eslint/js'; +import jsdoc from 'eslint-plugin-jsdoc'; +import prettier from 'eslint-config-prettier'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +export default [ + { + ignores: ['dist/**', 'build/**', 'node_modules/**'], + }, + + js.configs.recommended, + ...tseslint.configs.recommended, + jsdoc.configs['flat/recommended'], + + { + languageOptions: { + ecmaVersion: 2023, + sourceType: 'module', + globals: { + ...globals.browser, + ...globals.es2021, + }, + }, + plugins: {jsdoc}, + settings: { + jsdoc: { + // Blockly writes @fileoverview in every core file; the plugin's + // default is to rewrite it to @file. Invert that preference. + tagNamePreference: {file: 'fileoverview'}, + mode: 'typescript', + }, + }, + rules: { + // Correctness. + 'no-var': 'error', + 'prefer-const': 'error', + eqeqeq: ['error', 'always', {null: 'ignore'}], + 'no-throw-literal': 'error', + 'no-implicit-coercion': ['error', {boolean: false}], + + // The base rule double-reports on type-only imports and on enum-like + // declarations, so the TypeScript-aware version replaces it. + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': ['error', {argsIgnorePattern: '^_'}], + + // Blockly documents every exported symbol, and so does this package — + // but the types now live in the signatures, so the doc comment carries + // the prose and nothing else. + 'jsdoc/require-jsdoc': [ + 'error', + { + require: { + FunctionDeclaration: true, + MethodDefinition: true, + ClassDeclaration: true, + }, + }, + ], + 'jsdoc/require-param': 'error', + 'jsdoc/require-param-description': 'error', + 'jsdoc/require-returns': 'error', + 'jsdoc/require-returns-description': 'error', + + // Types belong to the compiler now. `no-types` is what keeps the old + // Closure annotations from creeping back in beside the real ones. + 'jsdoc/no-types': 'error', + 'jsdoc/require-param-type': 'off', + 'jsdoc/require-returns-type': 'off', + + 'jsdoc/require-description-complete-sentence': 'off', + 'jsdoc/tag-lines': 'off', + }, + }, + + { + // The playground and the mocha suites are development-only entry points, + // and are JavaScript for the toolchain reason described above. + files: ['test/**/*.js'], + languageOptions: { + globals: { + ...globals.browser, + ...globals.node, + ...globals.mocha, + }, + }, + rules: { + // Test callbacks are self-describing; requiring JSDoc on every suite + // and case would be noise rather than documentation. + 'jsdoc/require-jsdoc': 'off', + // The suites are plain JavaScript, so their doc comments still carry + // types where they help. + 'jsdoc/no-types': 'off', + }, + }, + + prettier, +]; diff --git a/blockly-workspace-notes/package-lock.json b/blockly-workspace-notes/package-lock.json new file mode 100644 index 0000000..549981e --- /dev/null +++ b/blockly-workspace-notes/package-lock.json @@ -0,0 +1,8514 @@ +{ + "name": "@mit-app-inventor/blockly-workspace-notes", + "version": "0.4.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@mit-app-inventor/blockly-workspace-notes", + "version": "0.4.0", + "license": "Apache-2.0", + "devDependencies": { + "@blockly/dev-scripts": "^13.2.0", + "@blockly/dev-tools": "^13.2.0", + "@eslint/js": "^10.0.1", + "blockly": "^13.2.1", + "eslint": "^10.10.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-jsdoc": "^64.3.6", + "globals": "^17.12.0", + "prettier": "^3.9.6", + "typescript": "5.9.3", + "typescript-eslint": "^8.70.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "blockly": "^13.2.1" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@babel/code-frame": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz", + "integrity": "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^8.0.0", + "js-tokens": "^10.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", + "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@blockly/block-test": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@blockly/block-test/-/block-test-13.2.0.tgz", + "integrity": "sha512-wOZYCt8HE8Q8FxpQhOJwxXT4m0odNKvSbUuGKHvoJU7Cba+sM04EZaXuOU0MNb4B84fCE7Z4c17vM9nHH1Us7g==", + "dev": true, + "license": "Apache 2.0", + "peerDependencies": { + "blockly": "^13.2.0" + } + }, + "node_modules/@blockly/dev-scripts": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@blockly/dev-scripts/-/dev-scripts-13.2.0.tgz", + "integrity": "sha512-fI/X3ndKFOeJRAnD57GyIjNI347zCGy/3wyRq1MRrio0k66Bz9ILzF3thNpfBg70ZOhh6iDTl7E/+uguKVDvDw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "chalk": "^4.1.2", + "fork-ts-checker-webpack-plugin": "^9.1.0", + "install": "^0.13.0", + "mocha": "^11.7.6", + "rimraf": "^6.1.3", + "source-map-loader": "^5.0.0", + "ts-loader": "^9.6.1", + "webpack": "^5.107.2", + "webpack-cli": "^7.0.3", + "webpack-dev-server": "^5.2.5" + }, + "bin": { + "blockly-scripts": "bin/blockly-scripts.js" + }, + "peerDependencies": { + "typescript": "^4.3.2 || ^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@blockly/dev-tools": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@blockly/dev-tools/-/dev-tools-13.2.0.tgz", + "integrity": "sha512-D0OdJwTsLV8h+lod7g1HNmfsHYmy+2TibKxWf0ywRsBDiObYa/QZ8UaqPPvN6Ixeq7pFEvoYQEH2TEHpG+p4yQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@blockly/block-test": "^13.2.0", + "@blockly/theme-dark": "^13.2.0", + "@blockly/theme-deuteranopia": "^13.2.0", + "@blockly/theme-highcontrast": "^13.2.0", + "@blockly/theme-tritanopia": "^13.2.0", + "chai": "^6.2.2", + "dat.gui": "^0.7.9", + "lodash.assign": "^4.2.0", + "lodash.merge": "^4.6.2", + "monaco-editor": "^0.55.1", + "sinon": "^22.0.0" + }, + "peerDependencies": { + "blockly": "^13.2.0" + } + }, + "node_modules/@blockly/theme-dark": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@blockly/theme-dark/-/theme-dark-13.2.0.tgz", + "integrity": "sha512-HSydDiVn7W8RIRTNZNtcvbrDbqx+8IjVOizGDy12AncZ65PfiTV7OsZxqeyn4J+oewNVhF5n3GtufxK/ca7iZA==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "blockly": "^13.2.0" + } + }, + "node_modules/@blockly/theme-deuteranopia": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@blockly/theme-deuteranopia/-/theme-deuteranopia-13.2.0.tgz", + "integrity": "sha512-on0Z/MpGQSUxacNXiaqpr7WCxeSXc1aP+XjN1+8zo8nm18JUY94WywXNpyiYD7Rbxxzx9uBX2OEcVXCwWPlMyQ==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "blockly": "^13.2.0" + } + }, + "node_modules/@blockly/theme-highcontrast": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@blockly/theme-highcontrast/-/theme-highcontrast-13.2.0.tgz", + "integrity": "sha512-87dUL4zckRSBDxLOLbwEvvF/ghARouKw5IM62rCCrOZUesZBrAJh8zuEhzdHEN7X6SybJWUmSTq+hR5EmHsUPw==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "blockly": "^13.2.0" + } + }, + "node_modules/@blockly/theme-tritanopia": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@blockly/theme-tritanopia/-/theme-tritanopia-13.2.0.tgz", + "integrity": "sha512-C7uaW57B3HOC/aZTvXYSWwXXAruftUw7nThbdyLaK2SDHuy0L2V8voxZywbxI/Dvda797XhZEt1SRigbFf6tww==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "blockly": "^13.2.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peer": true, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.2.tgz", + "integrity": "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.12.tgz", + "integrity": "sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peer": true, + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-1.1.0.tgz", + "integrity": "sha512-Xc3VhU02wqZ1HvHRJUwL09HkZSTvidqY5Ya0NXBSYOxAp+Ln9dcJr9fySI+CkONzP3PekQo9WdzCv0PGER/mOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.17.0" + } + }, + "node_modules/@es-joy/jsdoccomment": { + "version": "0.97.0", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.97.0.tgz", + "integrity": "sha512-EP8uoFfh6+GsdGCduYtmWAW0h7AO+Ayik9Vh5YbA2r/3N6lmJKkCNZX+q3QBXC1K6ixjQ/9igF2b7WVvLm063g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.9", + "@typescript-eslint/types": "^8.69.0", + "comment-parser": "1.4.8", + "esquery": "^1.7.0", + "jsdoc-type-pratt-parser": "~9.2.1" + }, + "engines": { + "node": "^22.22.2 || >=24.15.0" + } + }, + "node_modules/@es-joy/resolve.exports": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@es-joy/resolve.exports/-/resolve.exports-1.2.0.tgz", + "integrity": "sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jsonjoy.com/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/buffers": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-17.67.0.tgz", + "integrity": "sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/codegen": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz", + "integrity": "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-core": { + "version": "4.71.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.71.0.tgz", + "integrity": "sha512-9DFR/j+bm+tig1abs1CWS1/r0IVjNUdTp/+q6R/PXayXpO1rgbUh7tkanGYP40I0zdxbOreN3tmzBpVHgfMKzg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-builtins": "4.71.0", + "@jsonjoy.com/fs-node-utils": "4.71.0", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-fsa": { + "version": "4.71.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.71.0.tgz", + "integrity": "sha512-dRw5ojxzep3lntVGVBLzQUMYj1hXLH+DAz9sK0vKU+594x/zA2nYPOiitlyhYtOJ2nv1FXNq7c55rgwANknIWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.71.0", + "@jsonjoy.com/fs-node-builtins": "4.71.0", + "@jsonjoy.com/fs-node-utils": "4.71.0", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node": { + "version": "4.71.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.71.0.tgz", + "integrity": "sha512-yt5Ak0otPdHPIlmMvF16aLG2qSZL52EYJ7HOkYpBcIPWw+kmT1FtTSj4oiV2OUkJbG8pLzA+RhMgrlRl85QuBw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.71.0", + "@jsonjoy.com/fs-node-builtins": "4.71.0", + "@jsonjoy.com/fs-node-utils": "4.71.0", + "@jsonjoy.com/fs-print": "4.71.0", + "@jsonjoy.com/fs-snapshot": "4.71.0", + "glob-to-regex.js": "^1.0.0", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-builtins": { + "version": "4.71.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.71.0.tgz", + "integrity": "sha512-BSzl+QFSxZF58BxGjV1wqCJ+qSn3b2IZnb+z3hDQq6goqOO5RuxF8gRihjsFx17AfpEpa7XjYcP8XpQtDU8RrQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-to-fsa": { + "version": "4.71.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.71.0.tgz", + "integrity": "sha512-OlXBZKIeDx5bIGcQmn0w+nVkLheCiuQSepKiBAiWSWimSdn8Q6Yc9ih2y8zStcJk31fieqnov9V+o8XTWn/5Rw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-fsa": "4.71.0", + "@jsonjoy.com/fs-node-builtins": "4.71.0", + "@jsonjoy.com/fs-node-utils": "4.71.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-utils": { + "version": "4.71.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.71.0.tgz", + "integrity": "sha512-YtzCL3jbKYx6LHxqS9ymJ9Ob7SO9cucI+kpNO3LijGNFEjFbvNsTlHkvFgocAMAjUk96TpQTCsYkzFQi1CdlAA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-builtins": "4.71.0", + "glob-to-regex.js": "^1.0.1" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-print": { + "version": "4.71.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.71.0.tgz", + "integrity": "sha512-OhfDSdvyO8uGV0U11OP+mOeVCPgjuP1HqCGR/TdBQLxHoDEWJAK3KOP5fPXo57H7i3G0x0Vmv8xPRHDle4XGzA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-utils": "4.71.0", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot": { + "version": "4.71.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.71.0.tgz", + "integrity": "sha512-md+Wov365xa9A3nfW9YQTHLcweHHAee/e/0UoVRbldEHxboPJknuO3sEkNVVECO0dTqKra/ERaQylAMRrGWd0Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "^17.65.0", + "@jsonjoy.com/fs-node-utils": "4.71.0", + "@jsonjoy.com/json-pack": "^17.65.0", + "@jsonjoy.com/util": "^17.65.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/base64": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-17.67.0.tgz", + "integrity": "sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/codegen": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-17.67.0.tgz", + "integrity": "sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pack": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-17.67.0.tgz", + "integrity": "sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "17.67.0", + "@jsonjoy.com/buffers": "17.67.0", + "@jsonjoy.com/codegen": "17.67.0", + "@jsonjoy.com/json-pointer": "17.67.0", + "@jsonjoy.com/util": "17.67.0", + "hyperdyperid": "^1.2.0", + "thingies": "^2.5.0", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pointer": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-17.67.0.tgz", + "integrity": "sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/util": "17.67.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/util": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-17.67.0.tgz", + "integrity": "sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "17.67.0", + "@jsonjoy.com/codegen": "17.67.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.21.0.tgz", + "integrity": "sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "^1.1.2", + "@jsonjoy.com/buffers": "^1.2.0", + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/json-pointer": "^1.0.2", + "@jsonjoy.com/util": "^1.9.0", + "hyperdyperid": "^1.2.0", + "thingies": "^2.5.0", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack/node_modules/@jsonjoy.com/buffers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", + "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pointer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz", + "integrity": "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/util": "^1.9.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.9.0.tgz", + "integrity": "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "^1.0.0", + "@jsonjoy.com/codegen": "^1.0.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util/node_modules/@jsonjoy.com/buffers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", + "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.9.4.tgz", + "integrity": "sha512-cben7oxmQsUGZqotus7yt0srYdncOT6RNWcTQ77T2RFOXejYVYkXadrfePdRcrVpO9K95IRLKKglG2k38jKXuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-attr": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.9.4.tgz", + "integrity": "sha512-xd4YN4vpRjkDAQWVfZZkeu12IEND7DOpkqaHSIHxZl1uggUNa9Ju0QxY2jHvDAS9pP0zhRBytg8ifsnGo3V0jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.9.4.tgz", + "integrity": "sha512-JJXefFshRAuVAjWQo/39bkg1ywc1VaiO44S8RRC+Ykvf/u2KDmYffoDb0ZBPCR5uJy4AGKQhl8mX+Q8ShcWaXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.9.4.tgz", + "integrity": "sha512-khuGzHTzNzk4GDlIBEILyIs6Lce0yn0ZBdoI9v93kmNncfZRhD+AQ5ODFqdhvoE8cMJF/JMTQ8yA+t1D14kqCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-pkcs8": "^2.9.4", + "@peculiar/asn1-rsa": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.9.4.tgz", + "integrity": "sha512-duRdotlUx9eDZe6QrQpQKl61RbWykCHBCkKayP8V8XdEFwlKHZ8qGGDMyS6Pye7OX7nLFttTTpRkJeet78ckwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.9.4.tgz", + "integrity": "sha512-kaL4cNxBpdQE2dKlyZBqz4ygCrwffO+8wfoxTEqM1Z8RadvCeELBRzcv0dzM8aY9azHMwODO5nxU65zXmhToOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-pfx": "^2.9.4", + "@peculiar/asn1-pkcs8": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-attr": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.9.4.tgz", + "integrity": "sha512-pZ96eD1PptovcWQ/GSmuNFXd/7EQJNlKfDaNCyE2rx3W0v6QFelkzquVqRSRyyDXXCYD69ZXJDzZ8GhIiQzKoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz", + "integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.9.4.tgz", + "integrity": "sha512-CxhBo/RdEbMMob7T31ZdQjGuoyRFLVwrDzTn25bihzBasRg9kRm/0IxIPvhgQtcK/9dNcO1XQL2fuPugwELL0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.9.4.tgz", + "integrity": "sha512-ehQXbpQaQYycgu8OrvigwSPTFfVRcu0ECNYCWw+yzBp02Lw5paRqzzhUpfOgO2K38+WfFZuEz/0RPtam5g0OMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@sindresorhus/base62": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/base62/-/base62-1.0.0.tgz", + "integrity": "sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-10.0.2.tgz", + "integrity": "sha512-8lVwD1Df1BmzoaOLhMcGGcz/Jyr5QY2KSB75/YK1QgKzoabTeLdIVyhXNZK9ojfSKSdirbXqdbsXXqP9/Ve8+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "type-detect": "^4.1.0" + } + }, + "node_modules/@sinonjs/samsam/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/bonjour": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.13.tgz", + "integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect-history-api-fallback": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz", + "integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express-serve-static-core": "*", + "@types/node": "*" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.9", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz", + "integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-proxy": { + "version": "1.17.17", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz", + "integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.0.tgz", + "integrity": "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/retry": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", + "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-index": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz", + "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/sockjs": { + "version": "0.3.36", + "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", + "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", + "integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/type-utils": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.70.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz", + "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.0.tgz", + "integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.0.tgz", + "integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.70.0", + "@typescript-eslint/types": "^8.70.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz", + "integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz", + "integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz", + "integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz", + "integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz", + "integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.70.0", + "@typescript-eslint/tsconfig-utils": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.0.tgz", + "integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz", + "integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-html-community": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", + "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", + "dev": true, + "engines": [ + "node >= 0.8.0" + ], + "license": "Apache-2.0", + "bin": { + "ansi-html": "bin/ansi-html" + } + }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/are-docs-informative": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.1.1.tgz", + "integrity": "sha512-sqRsNQBwbKLRX0jV5Cu5uzmtflf892n4Vukz7T659ebL4pz3mpOqCMU7lxMoBTFwnp10E3YB5ZcyHM41W5bcDA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/batch": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", + "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/blockly": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/blockly/-/blockly-13.2.1.tgz", + "integrity": "sha512-iOa37H0ks5Zup2G1zZyviLX9E5WzWh3h0Zs8Nta2cPKmnAGHjZ6wrp3VqLTaquTJReJKPwaJZ39K7AD24rT4lg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "jsdom": ">=27.4.0 <30.0.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/bonjour-service": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.4.4.tgz", + "integrity": "sha512-jCZcVv7eoc4QesRscwEZtSROBen+6LpKAmBIsQYQrsAeVHLyMXWX/t6eIV5KiRZYNUBl8eVqImEEMQ8L5+c/Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true, + "license": "ISC" + }, + "node_modules/browserslist": { + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/comment-parser": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.8.tgz", + "integrity": "sha512-rKZTGo4fzKYna8UcL0isTg5wkBNla7bxTypLwZQXjIdi++IdP1OJ41rI5Mti3/jltkPujbu4i9LIARYA+zpotQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/connect-history-api-fallback": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", + "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/dat.gui": { + "version": "0.7.9", + "resolved": "https://registry.npmjs.org/dat.gui/-/dat.gui-0.7.9.tgz", + "integrity": "sha512-sCNc1OHobc+Erc1HqiswYgHdVNpSJUlk/Hz8vzOCsER7rl+oF/4+v8GXFUyCgtXpoCX6+bnmg07DedLvBLwYKQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/default-browser": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", + "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "dev": true, + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.422", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz", + "integrity": "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/envinfo": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.21.0.tgz", + "integrity": "sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow==", + "dev": true, + "license": "MIT", + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.10.0.tgz", + "integrity": "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-jsdoc": { + "version": "64.3.6", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-64.3.6.tgz", + "integrity": "sha512-lo7IXmgUUNy88SxW7KnJmmD2iPQBIRMomfCFPHidW1M3zNNVuzxlB2uoNrNyE1g4v2/L+RtYmiROPwQhSNzL8Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@es-joy/jsdoccomment": "~0.97.0", + "@es-joy/resolve.exports": "1.2.0", + "@typescript-eslint/utils": "^8.69.0", + "are-docs-informative": "^0.1.1", + "comment-parser": "1.4.8", + "debug": "^4.4.3", + "escape-string-regexp": "^5.0.0", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "html-entities": "^2.6.0", + "object-deep-merge": "^2.0.1", + "parse-imports-exports": "^0.2.4", + "semver": "^7.8.5", + "spdx-expression-parse": "^5.0.0", + "to-valid-identifier": "^1.0.0" + }, + "engines": { + "node": "^22.22.2 || >=24.15.0" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-jsdoc/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fork-ts-checker-webpack-plugin": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.1.0.tgz", + "integrity": "sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.16.7", + "chalk": "^4.1.2", + "chokidar": "^4.0.1", + "cosmiconfig": "^8.2.0", + "deepmerge": "^4.2.2", + "fs-extra": "^10.0.0", + "memfs": "^3.4.1", + "minimatch": "^3.0.4", + "node-abort-controller": "^3.0.1", + "schema-utils": "^3.1.1", + "semver": "^7.3.5", + "tapable": "^2.2.1" + }, + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "typescript": ">3.6.0", + "webpack": "^5.11.0" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs-monkey": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.1.0.tgz", + "integrity": "sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob-to-regex.js": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.2.0.tgz", + "integrity": "sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "17.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz", + "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handle-thing": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", + "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/hpack.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", + "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "obuf": "^1.0.0", + "readable-stream": "^2.0.1", + "wbuf": "^1.1.0" + } + }, + "node_modules/hpack.js/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/hpack.js/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/hpack.js/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/http-deceiver": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", + "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-middleware": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.10.tgz", + "integrity": "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "@types/express": "^4.17.13" + }, + "peerDependenciesMeta": { + "@types/express": { + "optional": true + } + } + }, + "node_modules/hyperdyperid": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", + "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.18" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/install": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/install/-/install-0.13.0.tgz", + "integrity": "sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/ipaddr.js": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-network-error": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz", + "integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdoc-type-pratt-parser": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-9.2.1.tgz", + "integrity": "sha512-V4Ww4EHnTcTLSOMoB0FsF72JhQvcAsriCm/LWnxJeGWoxIjEL2l9na11abQok5SYShq8m0Gl02el/xAbTCulvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.9", + "@types/node": "^26.4.0" + }, + "engines": { + "node": "^22.22.2 || >=24.15.0" + } + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/launch-editor": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.1.tgz", + "integrity": "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.1.1", + "shell-quote": "^1.8.4" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.assign": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-4.2.0.tgz", + "integrity": "sha512-hFuH8TY+Yji7Eja3mGiuAxBqLagejScbG8GbG0j6o9vzn0YL14My+ktnqtZgFTosKymC9/44wP6s7xyuLfnClw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0", + "peer": true + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memfs": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", + "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "fs-monkey": "^1.0.4" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimizer-webpack-plugin": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.10.0.tgz", + "integrity": "sha512-2//60T4S0X1Ug/dqLDOgDaGlZsN1Lv8hf8oB0NOV/KIHSaXlPwXBBIbim79dE2Z7NEs52ES8YHoSv6Lmsk+XNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.3", + "terser": "^5.51.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@napi-rs/image": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "imagemin": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "sharp": { + "optional": true + }, + "svgo": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimizer-webpack-plugin/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mocha": { + "version": "11.8.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.8.0.tgz", + "integrity": "sha512-VyCeUdGN3A9lmCTTgG4yuvY9ixxaDk+xt2R/7/+1AP6EqNG+G9OKkzBwhVtVYoNX8YsxNSgAl8mOv3IAeOpFbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "browser-stdout": "^1.3.1", + "chokidar": "^4.0.1", + "debug": "^4.3.5", + "diff": "^7.0.0", + "escape-string-regexp": "^4.0.0", + "find-up": "^5.0.0", + "glob": "^10.4.5", + "he": "^1.2.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimatch": "^9.0.5", + "ms": "^2.1.3", + "picocolors": "^1.1.1", + "serialize-javascript": "^6.0.2", + "strip-json-comments": "^3.1.1", + "supports-color": "^8.1.1", + "workerpool": "^9.2.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1", + "yargs-unparser": "^2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/mocha/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/mocha/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mocha/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "dev": true, + "license": "MIT", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-deep-merge": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/object-deep-merge/-/object-deep-merge-2.0.1.tgz", + "integrity": "sha512-aKttDKcU3pyZqKcCkDhsMn70WmZFG2JGDQLP9EcLyTSIFQRCPWLAmBZRLJnrVUrhPG1jETEEbfdgbNtJf1LyMg==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "dev": true, + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-retry": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz", + "integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.2", + "is-network-error": "^1.0.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-imports-exports": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz", + "integrity": "sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-statements": "1.0.11" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-json/node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/parse-json/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/parse-json/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse-statements": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/parse-statements/-/parse-statements-1.0.11.tgz", + "integrity": "sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkijs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", + "integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@noble/hashes": "1.4.0", + "asn1js": "^3.0.6", + "bytestreamjs": "^2.0.1", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/pkijs/node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-addr/node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/reserved-identifiers": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/reserved-identifiers/-/reserved-identifiers-1.2.0.tgz", + "integrity": "sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/select-hose": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", + "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", + "dev": true, + "license": "MIT" + }, + "node_modules/selfsigned": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-5.5.0.tgz", + "integrity": "sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/x509": "^1.14.2", + "pkijs": "^3.3.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/serve-index": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.2.tgz", + "integrity": "sha512-KDj11HScOaLmrPxl70KYNW1PksP4Nb/CLL2yvC+Qd2kHMPEEpfc4Re2e4FOay+bC/+XQl/7zAcWON3JVo5v3KQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "batch": "0.6.1", + "debug": "2.6.9", + "escape-html": "~1.0.3", + "http-errors": "~1.8.0", + "mime-types": "~2.1.35", + "parseurl": "~1.3.3" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-index/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/serve-index/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/serve-index/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sinon": { + "version": "22.1.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-22.1.0.tgz", + "integrity": "sha512-n1ajF2rBWMTtEwbKcw4UdFg4nCnDdq/U6RDoxtOd7oapOlRoJ5ynwFx60owROyhDpA9QhMZi0pCO/xtmwFjG7w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^15.4.0", + "@sinonjs/samsam": "^10.0.2", + "diff": "^9.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/diff": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/sockjs": { + "version": "0.3.24", + "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", + "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "faye-websocket": "^0.11.3", + "uuid": "^8.3.2", + "websocket-driver": "^0.7.4" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-loader": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-5.0.0.tgz", + "integrity": "sha512-k2Dur7CbSLcAH73sBcIkV5xjPV4SzqO1NJ7+XaQl8if3VODDUj3FNchNGpqgJSKbvUfJuhVdv8K2Eu8/TNl2eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.72.1" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-5.0.0.tgz", + "integrity": "sha512-vngmw3Rgn+o2arXNbnZaj5UtOEBuWBfvaI+Wc8GFfykIhA5/vdK9/Sp/XkLv63dykz2rxKDvKEHupF5P0FORcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/spdy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", + "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "handle-thing": "^2.0.0", + "http-deceiver": "^1.2.7", + "select-hose": "^2.0.0", + "spdy-transport": "^3.0.0" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/spdy-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", + "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "detect-node": "^2.0.4", + "hpack.js": "^2.1.6", + "obuf": "^1.1.2", + "readable-stream": "^3.0.6", + "wbuf": "^1.7.3" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.51.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz", + "integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/thingies": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.6.1.tgz", + "integrity": "sha512-cV/CMGTK3M4MlnJ/0At6ismOw/A0EEniDNScajjz/Br3c1sqE72YD01rGpPTKwd27wAxI5Pr+6+0w8yofzFRYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "^2" + } + }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tldts": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.12.tgz", + "integrity": "sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "tldts-core": "^7.4.12" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.12.tgz", + "integrity": "sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/to-valid-identifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/to-valid-identifier/-/to-valid-identifier-1.0.0.tgz", + "integrity": "sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/base62": "^1.0.0", + "reserved-identifiers": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tree-dump": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", + "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-loader": { + "version": "9.6.2", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.6.2.tgz", + "integrity": "sha512-R4iuczmtgxvtuI556s+hTZ6/7Ee03VCAk/l/M8LY1OAsUgB7YydsCxkgq9D9pKRaD7GJqUi2u8fp9zZP/ufjKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "picomatch": "^4.0.0", + "source-map": "^0.7.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "loader-utils": "*", + "typescript": "*", + "webpack": "^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "loader-utils": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.0.tgz", + "integrity": "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.70.0", + "@typescript-eslint/parser": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/watchpack": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wbuf": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", + "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=20" + } + }, + "node_modules/webpack": { + "version": "5.110.3", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.110.3.tgz", + "integrity": "sha512-GuizBzRvo9YPpyoNMf3ag7AzxbaW85qrRSqTha345KyJbAFPt3/cMzBM0h+RWg7SK/7DdzRLINP3LvQ0hvr4hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.24.4", + "es-module-lexer": "^2.1.0", + "events": "^3.2.0", + "graceful-fs": "^4.2.11", + "mime-db": "^1.54.0", + "minimizer-webpack-plugin": "^5.7.0", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "watchpack": "^2.5.2", + "webpack-sources": "^3.5.1" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-cli": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-7.2.3.tgz", + "integrity": "sha512-vDFU7jrfCctnN7jJQWPl+V26B51GLp11prVZXg50oeonsgeBzSTJEWmXkjHsOjTgMjlPOQM8GWLh33X9RN/0ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@discoveryjs/json-ext": "^1.1.0", + "commander": "^14.0.3", + "cross-spawn": "^7.0.6", + "envinfo": "^7.21.0", + "import-local": "^3.2.0", + "interpret": "^3.1.1", + "rechoir": "^0.8.0", + "webpack-merge": "^6.0.1" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "js-yaml": "^4.0.0 || ^5.0.0", + "json5": "^2.2.3", + "toml": "^3.0.0 || ^4.0.0 || ^5.0.0", + "webpack": "^5.101.0", + "webpack-bundle-analyzer": "^4.0.0 || ^5.0.0", + "webpack-dev-server": "^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "js-yaml": { + "optional": true + }, + "json5": { + "optional": true + }, + "toml": { + "optional": true + }, + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/webpack-cli/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/webpack-dev-middleware": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-7.4.6.tgz", + "integrity": "sha512-yBWCMvIfUmuhAE8vdqUKzH0vg9kuWN0KeG4vBnqRplUFHRU7lMQjkiJWxVQzvo2BTewqhPhDlMB41rAt2jVA9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "colorette": "^2.0.10", + "memfs": "^4.43.1", + "mime-types": "^3.0.1", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + } + } + }, + "node_modules/webpack-dev-middleware/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack-dev-middleware/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/webpack-dev-middleware/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/webpack-dev-middleware/node_modules/memfs": { + "version": "4.71.0", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.71.0.tgz", + "integrity": "sha512-Zwrk7TpTXBkic7taZL+2QeppbauG0QOufRsT1zuXDYLW8jCajH0W1VSZ17+I7ODqiNbH9J1Vf1QJCqFlCfurDg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.71.0", + "@jsonjoy.com/fs-fsa": "4.71.0", + "@jsonjoy.com/fs-node": "4.71.0", + "@jsonjoy.com/fs-node-builtins": "4.71.0", + "@jsonjoy.com/fs-node-to-fsa": "4.71.0", + "@jsonjoy.com/fs-node-utils": "4.71.0", + "@jsonjoy.com/fs-print": "4.71.0", + "@jsonjoy.com/fs-snapshot": "4.71.0", + "@jsonjoy.com/json-pack": "^1.11.0", + "@jsonjoy.com/util": "^1.9.0", + "glob-to-regex.js": "^1.0.1", + "thingies": "^2.5.0", + "tree-dump": "^1.0.3", + "tslib": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + } + }, + "node_modules/webpack-dev-middleware/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/webpack-dev-middleware/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/webpack-dev-server": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.6.tgz", + "integrity": "sha512-HNLRmamRvVavZQ+avceZifmv8hmdUjg43t6MI4SqJDwFdW7RPQwH5vzGhDRZSX59SgfbeHhLnq3g+uooWo7pVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/bonjour": "^3.5.13", + "@types/connect-history-api-fallback": "^1.5.4", + "@types/express": "^4.17.25", + "@types/express-serve-static-core": "^4.17.21", + "@types/serve-index": "^1.9.4", + "@types/serve-static": "^1.15.5", + "@types/sockjs": "^0.3.36", + "@types/ws": "^8.5.10", + "ansi-html-community": "^0.0.8", + "bonjour-service": "^1.2.1", + "chokidar": "^3.6.0", + "colorette": "^2.0.10", + "compression": "^1.8.1", + "connect-history-api-fallback": "^2.0.0", + "express": "^4.22.1", + "graceful-fs": "^4.2.6", + "http-proxy-middleware": "^2.0.9", + "ipaddr.js": "^2.1.0", + "launch-editor": "^2.14.1", + "open": "^10.0.3", + "p-retry": "^6.2.0", + "schema-utils": "^4.2.0", + "selfsigned": "^5.5.0", + "serve-index": "^1.9.1", + "sockjs": "^0.3.24", + "spdy": "^4.0.2", + "webpack-dev-middleware": "^7.4.2", + "ws": "^8.18.0" + }, + "bin": { + "webpack-dev-server": "bin/webpack-dev-server.js" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + }, + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-dev-server/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack-dev-server/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/webpack-dev-server/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/webpack-dev-server/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/webpack-dev-server/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/webpack-dev-server/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/webpack-dev-server/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/webpack-merge": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-6.0.1.tgz", + "integrity": "sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.1.tgz", + "integrity": "sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/webpack/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/websocket-driver": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz", + "integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/workerpool": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", + "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-unparser/node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/blockly-workspace-notes/package.json b/blockly-workspace-notes/package.json new file mode 100644 index 0000000..8874369 --- /dev/null +++ b/blockly-workspace-notes/package.json @@ -0,0 +1,84 @@ +{ + "name": "@mit-app-inventor/blockly-workspace-notes", + "version": "0.4.0", + "description": "Sticky notes for a Blockly workspace: draggable, resizable, colour-coded comments with a title, that round-trip through JSON and XML.", + "scripts": { + "audit:fix": "blockly-scripts auditFix", + "build": "blockly-scripts build && npm run build:types", + "clean": "blockly-scripts clean", + "predeploy": "blockly-scripts predeploy", + "start": "blockly-scripts start", + "test": "blockly-scripts test", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier --write .", + "format:check": "prettier --check .", + "prepack": "npm run clean && npm run build", + "build:types": "tsc --emitDeclarationOnly", + "typecheck": "tsc --noEmit", + "pretest": "npm run build:types" + }, + "main": "./dist/index.js", + "unpkg": "./dist/index.js", + "author": "", + "keywords": [ + "blockly", + "blockly-plugin", + "workspace-comment", + "comment", + "notes", + "sticky-notes", + "annotation", + "serialization" + ], + "homepage": "https://github.com/mit-cml/blockly-plugins/tree/main/blockly-workspace-notes#readme", + "bugs": { + "url": "https://github.com/mit-cml/blockly-plugins/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/mit-cml/blockly-plugins.git", + "directory": "blockly-workspace-notes" + }, + "license": "Apache-2.0", + "engines": { + "node": ">=20" + }, + "directories": { + "dist": "dist", + "src": "src" + }, + "files": [ + "dist", + "src", + "LICENSE", + "README.md" + ], + "devDependencies": { + "@blockly/dev-scripts": "^13.2.0", + "@blockly/dev-tools": "^13.2.0", + "@eslint/js": "^10.0.1", + "blockly": "^13.2.1", + "eslint": "^10.10.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-jsdoc": "^64.3.6", + "globals": "^17.12.0", + "prettier": "^3.9.6", + "typescript": "5.9.3", + "typescript-eslint": "^8.70.0" + }, + "peerDependencies": { + "blockly": "^13.2.1" + }, + "publishConfig": { + "access": "public" + }, + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + } +} diff --git a/blockly-workspace-notes/src/colour.ts b/blockly-workspace-notes/src/colour.ts new file mode 100644 index 0000000..bf86585 --- /dev/null +++ b/blockly-workspace-notes/src/colour.ts @@ -0,0 +1,79 @@ +/** + * @fileoverview Deriving a note's writing surface and edge from its colour. + * + * A note is paper. Its colour is a hue at a fixed saturation and value, as a + * block's is, but a pale one — where Blockly's `hueToHex` is S 0.45 at V 0.65 + * and carries a white label, a note is a light wash carrying black text. + * + * One stored colour paints the whole card; the text is written straight onto + * it. The only value read off it is the edge - the same hue a step down in + * value - which draws the card's outline and the hairline under the title. + */ + +import * as Blockly from 'blockly/core'; + +import {EDGE_VALUE_SCALE, NOTE_SATURATION, NOTE_VALUE} from './constants'; + +/** + * Converts a hex colour to HSV. + * + * Blockly offers `hsvToHex` but no inverse, so this supplies it. Hue is in + * degrees, saturation is 0-1 and value is 0-255, matching what `hsvToHex` + * expects back. + * + * @param hex A hex colour such as '#5b80a5'. + * @returns `[hue, saturation, value]`. + */ +export function hexToHsv(hex: string): [number, number, number] { + const [red, green, blue] = Blockly.utils.colour + .hexToRgb(hex) + .map((channel) => channel / 255); + + const max = Math.max(red, green, blue); + const min = Math.min(red, green, blue); + const delta = max - min; + + let hue = 0; + if (delta) { + if (max === red) hue = 60 * (((green - blue) / delta) % 6); + else if (max === green) hue = 60 * ((blue - red) / delta + 2); + else hue = 60 * ((red - green) / delta + 4); + } + if (hue < 0) hue += 360; + + return [hue, max ? delta / max : 0, max * 255]; +} + +/** + * Derives the shade a note's edges are drawn in. + * + * The same hue a step down in value. It draws the card's hairline and the + * border around the writing area — which is exactly what Blockly's own + * comment does, since core's `.blocklyTextarea` rule already reads this from + * `--commentBorderColour`. + * + * @param colour The note's colour, as a hex string. + * @returns The edge colour as hex. + */ +export function edgeFor(colour: string): string { + const [hue, saturation, value] = hexToHsv(colour); + return Blockly.utils.colour.hsvToHex( + hue, + saturation, + value * EDGE_VALUE_SCALE, + ); +} + +/** + * Returns the note colour for a hue. + * + * The note equivalent of Blockly's `hueToHex`, at the palette's own + * saturation and value rather than Blockly's, so notes stay distinct from the + * blocks they annotate. + * + * @param hue A hue in degrees. + * @returns The colour as hex. + */ +export function colourForHue(hue: number): string { + return Blockly.utils.colour.hsvToHex(hue, NOTE_SATURATION, NOTE_VALUE * 255); +} diff --git a/blockly-workspace-notes/src/constants.ts b/blockly-workspace-notes/src/constants.ts new file mode 100644 index 0000000..f947754 --- /dev/null +++ b/blockly-workspace-notes/src/constants.ts @@ -0,0 +1,206 @@ +/** + * @fileoverview Shared constants for the workspace notes plugin. + */ + +/** + * The name the note serializer registers under. This doubles as the top-level + * key in the JSON produced by `Blockly.serialization.workspaces.save()`. + */ +export const NOTE_SERIALIZER_NAME = 'workspaceNotes'; + +/** + * The name of Blockly's built-in workspace comment serializer, which we + * replace so that notes are not saved twice. + */ +export const COMMENT_SERIALIZER_NAME = 'workspaceComments'; + +/** + * Current version of the `workspaceNotes` payload. Bump this whenever the + * shape changes, and add a matching entry to MIGRATIONS in serializer.js. + */ +export const SCHEMA_VERSION = 1; + +/** + * The type string of the custom event used to make note-specific properties + * undoable. Namespaced to avoid colliding with other plugins in the global + * event registry. + */ +export const NOTE_CHANGE_EVENT_TYPE = 'workspace_note_change'; + +/** + * A note is paper: a light colour carrying dark text. + * + * That is the real difference from a block. Blockly's block language is a + * saturated fill with a white label — S 0.45 at V 0.65 via `hueToHex` — and + * anything built that way reads as a block whatever hue it uses. Notes invert + * it: a pale wash at V 0.98, with the text in black. + * + * It is also what Blockly's own comments do. Their default is `#FFFCC7`, a + * pale yellow, and nothing about a comment borrows the block palette. + */ +export const NOTE_SATURATION = 0.25; + +/** @type {number} */ +export const NOTE_VALUE = 0.98; + +/** Hue of the default note: the yellow a sticky note is expected to be. */ +export const DEFAULT_HUE = 48; + +/** The default note colour. */ +export const DEFAULT_COLOUR = '#f9edbb'; + +/** + * The swatches offered in the "Colour" context menu, in display order. + * + * Stationery colours rather than block colours. Each is stored as the hex the + * palette's saturation and value produce, since that is what a note + * serializes. + */ +export const DEFAULT_PALETTE = [ + {name: 'Yellow', hue: DEFAULT_HUE, fill: DEFAULT_COLOUR}, + {name: 'Peach', hue: 28, fill: '#f9d8bb'}, + {name: 'Pink', hue: 350, fill: '#f9bbc5'}, + {name: 'Lilac', hue: 275, fill: '#dfbbf9'}, + {name: 'Sky', hue: 200, fill: '#bbe5f9'}, + {name: 'Mint', hue: 150, fill: '#bbf9da'}, + {name: 'Grey', hue: 0, fill: '#f2f2f2'}, +]; + +/** CSS class added to the root SVG group of every note. */ +export const NOTE_CLASS = 'blocklyNote'; + +/** CSS class added to a note that has a non-empty title. */ +export const TITLED_CLASS = 'blocklyNoteTitled'; + +/** CSS class added to a pinned note. */ +export const PINNED_CLASS = 'blocklyNotePinned'; + +/** CSS class of the SVG text element that renders a note's title. */ +export const TITLE_CLASS = 'blocklyNoteTitle'; + +/** CSS class of the hairline drawn under a note's title. */ +export const RULE_CLASS = 'blocklyNoteRule'; + +/** + * Shown in the title row of a note that has not been named yet. + * + * A note always has a title row, so an unnamed one is labelled rather than + * left blank: the placeholder is what says the row can be clicked. + */ +export const UNTITLED_TITLE_TEXT = 'Title'; + +/** + * Blockly's renderer padding scale, from `renderers/common/constants.ts`. + * Note chrome is measured in these rather than in ad-hoc numbers. + */ +export const SMALL_PADDING = 3; + +/** @type {number} */ +export const MEDIUM_PADDING = 5; + +/** @type {number} */ +export const LARGE_PADDING = 10; + +/** + * Height reserved for one line of title text. Blockly's own icon size, which + * is the box a single line of field text is laid out in. + */ +export const TITLE_LINE_HEIGHT = 16; + +/** + * Type size of the title, in px. + * + * A heading has to be bigger than the text it heads or it is just bold body + * text. Core's field text is 11pt (14.67px) and the body inherits it, so the + * title takes the next size up and lands exactly on `TITLE_LINE_HEIGHT` - the + * line box the layout already reserves for it, which is why the row needs no + * remeasuring to fit it. + */ +export const TITLE_FONT_SIZE = TITLE_LINE_HEIGHT; + +/** + * The margin on every side of a note, and the single number the rest of the + * layout is built from. + * + * This one deliberately steps outside Blockly's chrome scale, which tops out + * at `LARGE_PADDING` 10. Block chrome is packed tight because a block is an + * operator with as much crammed onto it as will fit; paper is the opposite, + * and margins are most of what makes a page read as one. It is set to + * `TITLE_LINE_HEIGHT` so the margin is exactly one line of text on every + * side - the oldest rule in page layout, and the reason the note reads as + * even rather than as merely roomy. + */ +export const NOTE_MARGIN = TITLE_LINE_HEIGHT; + +/** + * Height of a note's title row: one line of text with a margin above and + * below. Core measures this rect and derives the writing area's offset from + * it, so it is what puts the body where it is. + */ +export const TOPBAR_HEIGHT = TITLE_LINE_HEIGHT + NOTE_MARGIN * 2; + +/** Corner radius of a note's card; Blockly's own CORNER_RADIUS. */ +export const FRAME_RADIUS = 8; + +/** + * Width of the body's scrollbar, for engines styled through + * ::-webkit-scrollbar. Half of `SMALL_PADDING` either side of a 2px thumb - + * narrow enough to read as a mark on the paper rather than as a control. + */ +export const SCROLLBAR_WIDTH = 8; + +/** Space between a note's edge and its writing area. */ +export const BODY_INSET = NOTE_MARGIN; + +/** + * Where the hairline under the title sits, measured from the note's top. + * + * Exactly midway between the bottom of the title's line box and the top of + * the body, so it has equal air above and below and does not read as + * belonging to either one. + * + * It separates the heading from the body the way the rule on an index card + * does. A box around the body would not: a lighter bordered panel inset in a + * coloured body is exactly how Blockly draws a field on a block, so anything + * built that way reads as a block however the note itself is shaped. + */ +const TITLE_BOTTOM = (TOPBAR_HEIGHT + TITLE_LINE_HEIGHT) / 2; +export const TITLE_RULE_Y = (TITLE_BOTTOM + TOPBAR_HEIGHT) / 2; + +/** + * The size a note is created at when the host sets no `defaultSize`. + * + * Blockly's own comment default is 120x100, which was chosen for a comment + * whose whole chrome is a 24px bar. A note's margins leave that barely two + * lines of text, so notes carry their own default. + */ +export const DEFAULT_SIZE = {width: 260, height: 180}; + +/** + * The smallest a note can be resized to. + * + * Core has a floor of its own, but it is not one a note can use. The width it + * enforces is the width of the truncated preview text - which a note hides, + * since the title stands in for it - so on a note with no body text the floor + * is zero, and the resize handle drags the paper away to nothing. What is left + * has no surface to grab and no title to read: the note is still there, still + * saved, and only undo brings it back. The height it enforces is the top bar + * plus 20px, which was measured for core's 24px bar and leaves a note less + * than one line of writing under its own title row. + * + * So a note sets its own, and states it in the terms the rest of the layout + * is built from: a note is never smaller than its title row plus one line of + * body and the margin under it, and never narrower than a title of a few + * characters between its two margins. Anything smaller is not a small note, + * it is a lost one. + */ +export const MIN_SIZE = { + width: NOTE_MARGIN * 2 + TITLE_LINE_HEIGHT * 4, + height: TOPBAR_HEIGHT + TITLE_LINE_HEIGHT + NOTE_MARGIN, +}; + +/** + * How far a note's edge sits below its own colour: the same hue, a step down + * in value. Used for the card's hairline and the writing area's border. + */ +export const EDGE_VALUE_SCALE = 0.88; diff --git a/blockly-workspace-notes/src/context_menu.ts b/blockly-workspace-notes/src/context_menu.ts new file mode 100644 index 0000000..4b811a0 --- /dev/null +++ b/blockly-workspace-notes/src/context_menu.ts @@ -0,0 +1,328 @@ +/** + * @fileoverview Context menu items for notes. + * + * Two things about core are worth knowing here. First, its comment menu items + * are *not* part of `registerDefaultOptions()` — `registerCommentOptions()` + * has to be called explicitly or there is no "Add Comment" at all. Second, its + * `commentCreate` hardcodes `new RenderedWorkspaceComment(...)` rather than + * going through `workspace.newComment()`, so overriding that method is not + * enough to make the menu produce notes; the item itself must be replaced. + * + * All three of core's items are replaced, the other two only so that the menu + * calls a note a note throughout rather than switching to "comment" for + * duplicate and delete. + */ + +import * as Blockly from 'blockly/core'; + +import {DEFAULT_PALETTE} from './constants'; +import type {PaletteEntry} from './types'; +import {Note, nextZIndex, previousZIndex} from './note'; + +const ScopeType = Blockly.ContextMenuRegistry.ScopeType; + +/** IDs of the items this module registers, in the order they are added. */ +const NOTE_ITEM_IDS = [ + 'noteColour', + 'notePin', + 'noteCollapse', + 'noteBringToFront', + 'noteSendToBack', +]; + +/** + * Reads a Blockly message with a fallback, since `blockly/core` on its own + * ships no message table. + * + * @param key The message key. + * @param fallback The text to use when the key is unset. + * @returns The message. + */ +function msg(key: string, fallback: string): string { + return Blockly.Msg[key] || fallback; +} + +/** + * @param scope The menu scope. + * @returns The note the menu was opened on, if any. + */ +function noteFromScope(scope: Blockly.ContextMenuRegistry.Scope): Note | null { + const comment = scope.comment; + return comment instanceof Note ? comment : null; +} + +/** + * Runs a mutation as a single undoable step. + * + * @param mutate The mutation to perform. + */ +function asOneUndoStep(mutate: () => void): void { + const existingGroup = Blockly.Events.getGroup(); + if (!existingGroup) Blockly.Events.setGroup(true); + try { + mutate(); + } finally { + Blockly.Events.setGroup(existingGroup); + } +} + +/** + * Builds the row of colour swatches used as a menu item's display text. + * + * Blockly's context menu has no notion of submenus, but `displayText` accepts + * an HTMLElement — so the whole palette fits in one row rather than spilling + * seven entries into the menu. + * + * @param note The note to recolour. + * @param palette The + * swatches. + * @returns The swatch row. + */ +function createSwatchRow(note: Note, palette: PaletteEntry[]): HTMLElement { + const row = document.createElement('div'); + row.className = 'blocklyNoteSwatchRow'; + + for (const {name, fill} of palette) { + const swatch = document.createElement('button'); + swatch.type = 'button'; + swatch.className = 'blocklyNoteSwatch'; + // The swatch shows the note's colour itself, the way a block's colour + // reads in the toolbox. + swatch.style.backgroundColor = fill; + swatch.title = name; + swatch.setAttribute('aria-label', name); + if (note.getColour().toLowerCase() === fill.toLowerCase()) { + swatch.classList.add('blocklyNoteSwatchSelected'); + } + + swatch.addEventListener('pointerdown', (e) => { + // Stop the menu's own handler from also firing for this row. + e.stopPropagation(); + e.preventDefault(); + asOneUndoStep(() => note.setColour(fill)); + Blockly.ContextMenu.hide(); + }); + + row.appendChild(swatch); + } + + return row; +} + +/** + * Registers every note-related context menu item. + * + * @param options Registration options. + * @param options.palette The colour swatches to offer. + */ +export function registerNoteContextMenu({palette = DEFAULT_PALETTE} = {}) { + const registry = Blockly.ContextMenuRegistry.registry; + + // Core does not register these by default; without them there is no + // "Add Comment" entry to replace. + if (!registry.getItem('commentCreate')) { + Blockly.ContextMenuItems.registerCommentOptions(); + } + + // Core's three comment items call a note a comment. Each is replaced below, + // keeping its ID, weight and keyboard shortcut so any host code referring to + // it still finds it and the menu still shows the shortcut hint. The wording + // falls back to core's own for a plain comment, which a host can still + // create directly even with this plugin installed. + registry.unregister('commentDuplicate'); + registry.register({ + id: 'commentDuplicate', + scopeType: ScopeType.COMMENT, + weight: 1, + associatedKeyboardShortcut: 'duplicate', + displayText: (scope) => + noteFromScope(scope) + ? msg('DUPLICATE_NOTE', 'Duplicate note') + : msg('DUPLICATE_COMMENT', 'Duplicate Comment'), + preconditionFn: (scope) => + scope.comment?.isMovable() ? 'enabled' : 'hidden', + callback: (scope) => { + const comment = scope.comment; + const data = comment?.toCopyData(); + if (!comment || !data) return; + Blockly.clipboard.paste(data, comment.workspace); + }, + }); + + registry.unregister('commentDelete'); + registry.register({ + id: 'commentDelete', + scopeType: ScopeType.COMMENT, + weight: 6, + associatedKeyboardShortcut: 'delete', + displayText: (scope) => + noteFromScope(scope) + ? msg('DELETE_NOTE', 'Delete note') + : msg('REMOVE_COMMENT', 'Remove Comment'), + preconditionFn: (scope) => + scope.comment?.isDeletable() ? 'enabled' : 'hidden', + callback: (scope) => { + const comment = scope.comment; + if (!comment) return; + asOneUndoStep(() => comment.dispose()); + comment.workspace.getAudioManager().play('delete'); + }, + }); + + registry.unregister('commentCreate'); + registry.register({ + id: 'commentCreate', + scopeType: ScopeType.WORKSPACE, + weight: 8, + displayText: () => msg('ADD_COMMENT', 'Add Note'), + preconditionFn: (scope) => + scope.workspace?.isMutator ? 'hidden' : 'enabled', + callback: (scope, menuOpenEvent, menuSelectEvent, location) => { + const workspace = scope.workspace; + if (!workspace) return; + asOneUndoStep(() => { + const note = new Note(workspace); + note.moveTo( + Blockly.utils.svgMath.screenToWsCoordinates( + workspace, + new Blockly.utils.Coordinate(location.x, location.y), + ), + ); + note.setZIndex(nextZIndex(workspace)); + Blockly.getFocusManager().focusNode(note); + }); + }, + }); + + registry.register({ + id: 'noteColour', + scopeType: ScopeType.COMMENT, + weight: 3, + displayText: (scope) => { + const note = noteFromScope(scope); + return note ? createSwatchRow(note, palette) : ''; + }, + preconditionFn: (scope) => { + const note = noteFromScope(scope); + return note?.isEditable() ? 'enabled' : 'hidden'; + }, + // Selecting the row itself does nothing; the swatches handle their own + // clicks so that one press picks a colour and dismisses the menu. + callback: () => {}, + }); + + registry.register({ + id: 'notePin', + scopeType: ScopeType.COMMENT, + weight: 4, + displayText: (scope) => + noteFromScope(scope)?.isPinned() + ? msg('UNPIN_NOTE', 'Unpin note') + : msg('PIN_NOTE', 'Pin note'), + preconditionFn: (scope) => (noteFromScope(scope) ? 'enabled' : 'hidden'), + callback: (scope) => { + const note = noteFromScope(scope); + if (!note) return; + asOneUndoStep(() => { + const pinning = !note.isPinned(); + if (pinning) note.setZIndex(nextZIndex(note.workspace)); + note.setPinned(pinning); + }); + }, + }); + + // Core registers no collapse item for comments - registerCollapseExpandBlock + // is ScopeType.BLOCK only - so with the foldout arrow gone from the bar this + // is the only way to collapse a note. + registry.register({ + id: 'noteCollapse', + scopeType: ScopeType.COMMENT, + weight: 4.1, + displayText: (scope) => + noteFromScope(scope)?.isCollapsed() + ? msg('EXPAND_NOTE', 'Expand note') + : msg('COLLAPSE_NOTE', 'Collapse note'), + preconditionFn: (scope) => (noteFromScope(scope) ? 'enabled' : 'hidden'), + callback: (scope) => { + const note = noteFromScope(scope); + if (!note) return; + asOneUndoStep(() => note.setCollapsed(!note.isCollapsed())); + }, + }); + + registry.register({ + id: 'noteBringToFront', + scopeType: ScopeType.COMMENT, + weight: 4.2, + displayText: () => msg('NOTE_BRING_TO_FRONT', 'Bring to front'), + preconditionFn: (scope) => (noteFromScope(scope) ? 'enabled' : 'hidden'), + callback: (scope) => { + const note = noteFromScope(scope); + if (!note) return; + asOneUndoStep(() => note.setZIndex(nextZIndex(note.workspace))); + }, + }); + + registry.register({ + id: 'noteSendToBack', + scopeType: ScopeType.COMMENT, + weight: 4.3, + displayText: () => msg('NOTE_SEND_TO_BACK', 'Send to back'), + preconditionFn: (scope) => (noteFromScope(scope) ? 'enabled' : 'hidden'), + callback: (scope) => { + const note = noteFromScope(scope); + if (!note) return; + asOneUndoStep(() => note.setZIndex(previousZIndex(note.workspace))); + }, + }); +} + +/** + * Removes the note items and restores core's `commentCreate`. + */ +export function unregisterNoteContextMenu(): void { + const registry = Blockly.ContextMenuRegistry.registry; + + for (const id of NOTE_ITEM_IDS) { + if (registry.getItem(id)) registry.unregister(id); + } + + // Hand core's own three items back, in the order it registers them. + if (registry.getItem('commentDuplicate')) { + registry.unregister('commentDuplicate'); + Blockly.ContextMenuItems.registerCommentDuplicate(); + } + + if (registry.getItem('commentDelete')) { + registry.unregister('commentDelete'); + Blockly.ContextMenuItems.registerCommentDelete(); + } + + if (registry.getItem('commentCreate')) { + registry.unregister('commentCreate'); + Blockly.ContextMenuItems.registerCommentCreate(); + } +} + +Blockly.Css.register(` +.blocklyNoteSwatchRow { + display: flex; + gap: 6px; + padding: 2px 0; +} + +.blocklyNoteSwatch { + width: 18px; + height: 18px; + padding: 0; + border: 1px solid rgba(0, 0, 0, 0.25); + border-radius: 4px; + cursor: pointer; +} + +/* #fc3 is the selection colour core uses for comments and blocks. */ +.blocklyNoteSwatchSelected { + outline: 2px solid #fc3; + outline-offset: 1px; +} +`); diff --git a/blockly-workspace-notes/src/css.ts b/blockly-workspace-notes/src/css.ts new file mode 100644 index 0000000..3def4a4 --- /dev/null +++ b/blockly-workspace-notes/src/css.ts @@ -0,0 +1,311 @@ +/** + * @fileoverview Styles for workspace notes. + * + * Registered at module load: Blockly.Css.register only takes effect for + * injections that happen afterwards, so importing this plugin before calling + * Blockly.inject is required. + * + * A note is a plain rounded card. Almost everything here is a small + * adjustment to elements core already builds and already sizes - the card is + * core's own highlight rect, and the writing area is core's own textarea - so + * that resizing, collapsing and selection keep working without help. + * + * Colours come from the --commentFillColour / --commentBorderColour custom + * properties core's comment stylesheet reads, plus one of ours for the + * writing area's fill. + * + * NOTE: this stylesheet is a JavaScript template literal. A backtick anywhere + * inside it, including in a comment quoting a selector, silently ends the + * literal. npm run build will not catch that, because it is a runtime error + * rather than a syntax one; npm test will. + */ + +import * as Blockly from 'blockly/core'; + +import { + BODY_INSET, + NOTE_CLASS, + PINNED_CLASS, + RULE_CLASS, + SCROLLBAR_WIDTH, + TITLED_CLASS, + TITLE_CLASS, + TITLE_FONT_SIZE, + TOPBAR_HEIGHT, +} from './constants'; + +Blockly.Css.register(` +/* + * The sheet of paper. This is core's own highlight rect, which it resizes on + * every pointer move of a drag and strokes when the note is selected; the + * rounded corners are set as attributes by the note itself. + */ +.${NOTE_CLASS} .blocklyCommentHighlight { + fill: var(--commentFillColour); + stroke: var(--commentBorderColour); + stroke-width: 1px; +} + +/* + * A pinned note is locked in place, and says so with a heavier edge - the + * quietest mark available now that the bar carries no icons. + */ +.${NOTE_CLASS}.${PINNED_CLASS} .blocklyCommentHighlight { + stroke-width: 2px; +} + +/* + * No header strip: the title sits on the paper. The rect stays, because core + * measures its rendered height and derives the writing area's offset from it. + */ +.${NOTE_CLASS} .blocklyCommentTopbarBackground { + fill: none; + height: ${TOPBAR_HEIGHT}px; +} + +/* + * Every action is in the context menu, so the bar carries no buttons. CSS is + * how core hides one itself - it ships .blocklyDeleteIcon as display:none - + * and CommentBarButton.canBeFocused() defers to checkVisibility(), so + * keyboard navigation skips a hidden button rather than trapping on it. + */ +.${NOTE_CLASS} .blocklyFoldoutIcon, +.${NOTE_CLASS} .blocklyDeleteIcon { + display: none; +} + +/* + * You write on the paper, not in a box on it. + * + * Core gives its textarea a fill and a 1px border, which is what makes a + * comment read as a panel - and a lighter bordered panel inset in a coloured + * body is precisely how Blockly draws a field on a block. Both come off here, + * so the note stays one flat colour and only the rule under the title divides + * it. Core's own 5px padding goes too, so the body's first character lines up + * with the first letter of the title rather than sitting 5px right of it. + */ +.${NOTE_CLASS} .blocklyMinimalBody { + box-sizing: border-box; + padding: 0 ${BODY_INSET}px ${BODY_INSET}px; +} + +/* + * The writing area itself: core's fill, border and padding off, and the + * scrollbar quietened. + * + * Core's textarea scrolls with the platform's own bar, and on a system set to + * show scrollbars always - rather than as an overlay that fades - that is a + * full-width white track down the side of the sheet: the one piece of + * furniture on a note that otherwise carries nothing but the words on it. + * + * So it is made quiet rather than removed. Removing it outright would take the + * only sign that there is more text below, on the one element of a note that + * can have more to show than fits. Instead the gutter is narrowed and always + * reserved - the text keeps its width whether the bar is painted or not, so + * nothing reflows - and the paint is what changes: nothing at rest, and while + * the pointer is on the note or the caret is in it, a thumb in the paper's own + * edge colour, the same hairline that draws the card and the rule. + */ +.${NOTE_CLASS} .blocklyTextarea { + background-color: transparent; + border: none; + padding: 0; + scrollbar-width: thin; + scrollbar-color: transparent transparent; +} + +.${NOTE_CLASS}:hover .blocklyTextarea, +.${NOTE_CLASS} .blocklyTextarea:focus { + scrollbar-color: var(--commentBorderColour) transparent; +} + +/* + * The same for engines without scrollbar-color (Safari, and Chrome before + * 121). Chrome 121+ ignores these once the standard properties above are set + * to anything but auto, so the two cannot both apply and disagree. + */ +.${NOTE_CLASS} .blocklyTextarea::-webkit-scrollbar { + width: ${SCROLLBAR_WIDTH}px; +} + +.${NOTE_CLASS} .blocklyTextarea::-webkit-scrollbar-track { + background: transparent; +} + +.${NOTE_CLASS} .blocklyTextarea::-webkit-scrollbar-thumb { + background: transparent; + border-radius: ${SCROLLBAR_WIDTH / 2}px; +} + +.${NOTE_CLASS}:hover .blocklyTextarea::-webkit-scrollbar-thumb, +.${NOTE_CLASS} .blocklyTextarea:focus::-webkit-scrollbar-thumb { + background: var(--commentBorderColour); +} + +/* + * Where there is no pointer there is no hover, and a rule that only paints on + * hover would leave a touch device with a permanently invisible scrollbar - + * including while a finger is actually dragging the text. So on those the + * thumb is simply always painted; it is thin and in the paper's own colour, + * which was the point. + */ +@media (hover: none) { + .${NOTE_CLASS} .blocklyTextarea { + scrollbar-color: var(--commentBorderColour) transparent; + } + + .${NOTE_CLASS} .blocklyTextarea::-webkit-scrollbar-thumb { + background: var(--commentBorderColour); + } +} + +/* + * The hairline under the title. Hidden on a collapsed note, where the title + * row is the whole note and there is no body to divide it from. + */ +.${NOTE_CLASS} .${RULE_CLASS} { + stroke: var(--commentBorderColour); + stroke-width: 1px; +} + +.${NOTE_CLASS}.blocklyCollapsed .${RULE_CLASS} { + display: none; +} + +/* Bring the resize handle inside the sheet instead of over its corner. */ +.${NOTE_CLASS} .blocklyResizeHandle { + transform: translate(-${BODY_INSET}px, -${BODY_INSET}px); +} + +.blocklyRTL .${NOTE_CLASS} .blocklyResizeHandle { + transform: scale(-1, 1) translate(-${BODY_INSET}px, -${BODY_INSET}px); +} + +/* + * Title. A heading on the paper, so it takes the weight of one, and a click + * opens its editor the way a click on a field does. + * + * The type is set below rather than here: the renderer writes the font + * SHORTHAND, which resets weight and size, so a rule at this specificity + * would lose both. + */ +.${TITLE_CLASS} { + dominant-baseline: middle; + user-select: none; + cursor: text; +} + +.blocklyReadonly.blocklyComment .${TITLE_CLASS} { + cursor: inherit; +} + +/* + * The selectors below are deliberately over-specific. The renderer generates + * a .blocklyText rule with fill #fff at three classes + * (.thrasos-renderer.classic-theme .blocklyText), so a single-class rule + * loses to it and the title comes out white on pale paper; four here means + * the outcome does not depend on which stylesheet was injected last. + * + * The type has to be set here for the same reason, and it is the sharper of + * the two traps: alongside that fill rule the renderer writes + * + * .thrasos-renderer.classic-theme .blocklyText { font: normal 11pt sans-serif; } + * + * and font is a SHORTHAND, so it resets font-weight and font-size to the + * theme's field values every time. A plain .blocklyNoteTitle { font-weight: + * bold } is therefore not merely outranked, it is overwritten - which is why + * the title rendered at body weight and body size, indistinguishable from the + * text it heads. + */ +.${NOTE_CLASS}.blocklyComment .${TITLE_CLASS}.blocklyText { + fill: #000; + font-size: ${TITLE_FONT_SIZE}px; + font-weight: bold; +} + +/* + * An unnamed note shows a placeholder rather than an empty row, greyed so it + * reads as a prompt and not as a title someone typed. + */ +.${NOTE_CLASS}.blocklyComment:not(.${TITLED_CLASS}) .${TITLE_CLASS}.blocklyText { + fill: #999; +} + +.blocklyRTL .${TITLE_CLASS} { + /* Revert the top bar's mirroring, matching core's .blocklyCommentPreview. */ + transform: scale(-1, 1); + direction: rtl; +} + +/* + * Core's truncated body preview never shows: the title stands in for it, both + * on an expanded note and on a collapsed one. The second selector is needed + * because core reveals the preview once a comment collapses, with + * .blocklyCollapsed.blocklyComment .blocklyCommentPreview, which outranks a + * two-class rule. + */ +.${NOTE_CLASS} .blocklyCommentPreview, +.${NOTE_CLASS}.blocklyComment.blocklyCollapsed .blocklyCommentPreview { + visibility: hidden; +} + +/* + * The title's editor, which is meant to be invisible. + * + * .blocklyHtmlInput is Blockly's field editor, and a field editor is supposed + * to announce itself - it sits centred in a white box over the block. On a + * note that reads as a mode: a panel opens on the paper. Everything that draws + * the box comes off, so clicking the title just puts a caret in it and the + * text carries on looking like the heading it already was. + * + * The background is the browser's own input default rather than anything + * Blockly sets, which is why it has to be cleared explicitly. + * + * The type is not here: the size is scaled by the workspace zoom, and the + * weight has to beat a three-class renderer rule, so title_editor.ts sets + * both inline where the scale is known. + */ +.blocklyNoteTitleInput { + background: transparent; + color: #000; + text-align: left; + padding: 0; +} + +.blocklyNoteTitleInput::placeholder { + color: #999; + opacity: 1; +} + +.blocklyRTL .blocklyNoteTitleInput { + text-align: right; +} + +/* The title is hidden while its editor is open, as a field's label is. */ +.blocklyEditing .${TITLE_CLASS} { + visibility: hidden; +} + +/* + * Selection, last in this sheet and over-specific on purpose. + * + * Core's ring is .blocklySelected .blocklyCommentHighlight - two classes, + * exactly what the card rule above is - and this stylesheet is registered + * after core's, so without a third class the card's own hairline would + * quietly win and a selected note would show no ring at all. + * + * The collapsed selectors undo core's own pair, which drops the ring from the + * highlight rect and moves it onto the top bar. That is right for a comment + * whose bar is its whole collapsed body; here the card is still the shape to + * outline, and the bar has no fill to carry a stroke. + */ +.blocklySelected.${NOTE_CLASS} .blocklyCommentHighlight, +.blocklySelected.${NOTE_CLASS}.blocklyCollapsed .blocklyCommentHighlight { + stroke: #fc3; + stroke-width: 3px; +} + +.blocklySelected.${NOTE_CLASS}.blocklyCollapsed .blocklyCommentTopbarBackground { + stroke: none; +} +`); diff --git a/blockly-workspace-notes/src/events.ts b/blockly-workspace-notes/src/events.ts new file mode 100644 index 0000000..1e8d428 --- /dev/null +++ b/blockly-workspace-notes/src/events.ts @@ -0,0 +1,147 @@ +/** + * @fileoverview A custom Blockly event making note-specific properties + * undoable. + * + * Core fires nothing for `setMovable`/`setEditable`/`setDeletable`, and + * `CommentCreate` snapshots a note through core's comment serializer — which + * knows nothing about titles or colours. Without this event, undo would + * silently drop everything the plugin adds. + */ + +import * as Blockly from 'blockly/core'; + +import type {NoteChangeJson, NoteProperty, NotePropertyValue} from './types'; +import {NOTE_CHANGE_EVENT_TYPE} from './constants'; + +/** + * The part of a note this event needs in order to replay itself. + * + * Structural rather than importing `Note`: the event module sits below the + * note module in the dependency graph, and only ever calls this one method. + */ +interface NoteLike { + applyNoteProperty(property: NoteProperty, value?: NotePropertyValue): void; +} + +/** + * Notifies listeners that a note-specific property changed. + * + * The `property` is a key understood by `Note.applyNoteProperty()`; `oldValue` + * and `newValue` are the JSON-serializable values on either side of the + * change. The special property `'*'` carries a whole note-state object, which + * is how a note's extra fields survive being deleted and undone. + */ +export class NoteChange extends Blockly.Events.CommentBase { + /** The property that changed, or undefined on a blank event. */ + property?: NoteProperty; + + /** The value before the change. */ + oldValue?: NotePropertyValue; + + /** The value after the change. */ + newValue?: NotePropertyValue; + + /** + * @param comment The note that changed. Undefined for a blank event. + * @param property The property that changed. + * @param oldValue The value before the change. + * @param newValue The value after the change. + */ + constructor( + comment?: Blockly.comments.WorkspaceComment, + property?: NoteProperty, + oldValue?: NotePropertyValue, + newValue?: NotePropertyValue, + ) { + super(comment); + this.type = NOTE_CHANGE_EVENT_TYPE; + this.property = property; + this.oldValue = oldValue; + this.newValue = newValue; + } + + /** + * Encodes the event as JSON. + * @returns JSON representation. + */ + toJson(): NoteChangeJson { + const json = super.toJson() as NoteChangeJson; + json['property'] = this.property; + json['oldValue'] = this.oldValue; + json['newValue'] = this.newValue; + return json; + } + + /** + * Deserializes the JSON event. + * @param json The JSON to decode. + * @param workspace The workspace the event belongs to. + * @param [event] An event to populate, for subclasses. + * @returns The decoded event. + */ + static fromJson( + json: NoteChangeJson, + workspace: Blockly.Workspace, + event?: NoteChange, + ): NoteChange { + const newEvent = super.fromJson( + json, + workspace, + event ?? new NoteChange(), + ) as NoteChange; + newEvent.property = json['property']; + newEvent.oldValue = json['oldValue']; + newEvent.newValue = json['newValue']; + return newEvent; + } + + /** + * Does this event record any change of state? + * + * Values may be objects (for the `'*'` property), so compare structurally. + * Filtering no-ops here keeps them off the undo stack — core's + * `CommentCollapse` omits this and pollutes the stack as a result. + * @returns False if something changed. + */ + isNull(): boolean { + return JSON.stringify(this.oldValue) === JSON.stringify(this.newValue); + } + + /** + * Runs the change event. + * @param forward True to run forward, false to undo. + */ + run(forward: boolean): void { + const workspace = this.getEventWorkspace_(); + const note = this.commentId + ? (workspace.getCommentById(this.commentId) as NoteLike | null) + : null; + if (!note || typeof note.applyNoteProperty !== 'function') { + // Matches core's tolerance for replaying against a vanished object. + console.warn(`Can't change non-existent note: ${this.commentId}`); + return; + } + if (!this.property) return; + note.applyNoteProperty( + this.property, + forward ? this.newValue : this.oldValue, + ); + } +} + +/** + * Registers NoteChange so `Blockly.Events.fromJson` can rebuild it. Safe to + * call repeatedly; re-registering the identical class is a no-op in Blockly's + * registry, and a duplicate-name throw would only mean it is already present. + */ +export function registerNoteChangeEvent(): void { + try { + Blockly.registry.register( + Blockly.registry.Type.EVENT, + NOTE_CHANGE_EVENT_TYPE, + NoteChange, + ); + } catch { + // Already registered by another copy of the plugin. + } +} diff --git a/blockly-workspace-notes/src/index.ts b/blockly-workspace-notes/src/index.ts new file mode 100644 index 0000000..c4bfb68 --- /dev/null +++ b/blockly-workspace-notes/src/index.ts @@ -0,0 +1,230 @@ +/** + * @fileoverview A Blockly plugin adding sticky-note style workspace comments + * with first-class JSON serialization. + * + * Notes extend Blockly's own workspace comments — so dragging, resizing, + * collapsing, selection, keyboard navigation and undo all come for free — and + * add a title, a colour, authorship metadata and a stacking order. They are + * saved under their own versioned `workspaceNotes` key alongside `blocks`, and + * older `workspaceComments` files still load. + * + * @example + * import * as Blockly from 'blockly'; + * import {WorkspaceNotes} from '@mit-app-inventor/blockly-workspace-notes'; + * + * const workspace = Blockly.inject('blocklyDiv', {toolbox}); + * const notes = new WorkspaceNotes(workspace); + * notes.init(); + */ + +import * as Blockly from 'blockly/core'; + +import './css'; +import { + registerNoteContextMenu, + unregisterNoteContextMenu, +} from './context_menu'; +import type {SavedNote, WorkspaceNotesOptions} from './types'; +import {DEFAULT_PALETTE, DEFAULT_SIZE} from './constants'; +import {Note, NoteComment, isNote, nextZIndex} from './note'; +import {registerNoteChangeEvent} from './events'; +import {registerNotePaster, unregisterNotePaster} from './paster'; +import { + createNote as makeNote, + registerNoteSerializers, + unregisterNoteSerializers, +} from './serializer'; +import {registerXmlSupport, unregisterXmlSupport} from './xml'; + +/** + * Adds note support to a workspace. + */ +export class WorkspaceNotes { + /** + * @param workspace The workspace to add notes to. A + * headless workspace works too; it simply gets unrendered notes. + * @param {{ + * palette?: !Array<{name: string, hue: number, fill: string}>, + * defaultSize?: {width: number, height: number}, + * getAuthor?: function(): string, + * contextMenu?: boolean, + * skipSerializerRegistration?: boolean, + * emitLegacyComments?: boolean, + * xmlSupport?: boolean, + * }} [options] Plugin options. `skipSerializerRegistration` leaves + * persistence entirely to the host app; `emitLegacyComments` keeps + * writing the old `workspaceComments` key as well, which duplicates + * every note and is off by default; `xmlSupport` wraps the + * `Blockly.Xml` entry points so notes survive the older XML format + * too, and can be turned off by a host that only uses JSON. + */ + /** The workspace this instance is attached to. */ + protected workspace: Blockly.Workspace; + + /** The options this instance was constructed with, over the defaults. */ + protected options: Required; + + /** The workspace's own `newComment`, while ours is in its place. */ + private originalNewComment_: + ((id?: string) => Blockly.comments.WorkspaceComment) | null = null; + + /** Whether `init` has run, so it stays idempotent. */ + private initialized_ = false; + + /** + * @param workspace The workspace to add notes to. A headless workspace works + * too; it simply gets unrendered notes. + * @param options Plugin options. + */ + constructor( + workspace: Blockly.Workspace, + options: WorkspaceNotesOptions = {}, + ) { + this.workspace = workspace; + this.options = { + palette: DEFAULT_PALETTE, + defaultSize: DEFAULT_SIZE, + contextMenu: true, + skipSerializerRegistration: false, + emitLegacyComments: false, + xmlSupport: true, + getAuthor: () => '', + ...options, + }; + } + + /** + * Starts the plugin. + */ + init(): void { + if (this.initialized_) return; + this.initialized_ = true; + + // Blockly's own default is sized for a comment whose whole chrome is a + // 24px bar; a note's margins would leave that barely two lines. + const {width, height} = this.options.defaultSize; + Blockly.comments.CommentView.defaultCommentSize = new Blockly.utils.Size( + width, + height, + ); + + // Patched on the instance rather than the prototype: scoped to this + // workspace and trivially reversible. This is what makes undoing a delete + // rebuild a Note — core's CommentCreate replays through + // `workspace.newComment()`. + this.originalNewComment_ = this.workspace.newComment; + this.workspace.newComment = (id) => makeNote(this.workspace, id); + + registerNoteChangeEvent(); + + if (!this.options.skipSerializerRegistration) { + registerNoteSerializers({ + emitLegacyComments: this.options.emitLegacyComments, + }); + } + + registerNotePaster(); + + if (this.options.xmlSupport) { + registerXmlSupport(); + } + + if (this.options.contextMenu) { + registerNoteContextMenu({palette: this.options.palette}); + } + } + + /** + * Stops the plugin and restores everything it replaced. + * + * The serializer, paster and context menu registries are global singletons + * shared by every workspace, so they are reference-counted and only really + * restored once the last plugin instance is disposed. + */ + dispose(): void { + if (!this.initialized_) return; + this.initialized_ = false; + + if (this.originalNewComment_) { + this.workspace.newComment = this.originalNewComment_; + this.originalNewComment_ = null; + } + + if (!this.options.skipSerializerRegistration) { + unregisterNoteSerializers(); + } + + unregisterNotePaster(); + + if (this.options.xmlSupport) { + unregisterXmlSupport(); + } + + if (this.options.contextMenu) { + unregisterNoteContextMenu(); + } + } + + /** + * Creates a note on the workspace. + * + * @param state Initial values for the note. + * @returns The new note. + */ + createNote(state: Partial = {}): Note | NoteComment { + const existingGroup = Blockly.Events.getGroup(); + if (!existingGroup) Blockly.Events.setGroup(true); + try { + const note = makeNote(this.workspace); + if (state.text) note.setText(state.text); + if (state.title) note.setTitle(state.title); + if (state.colour) note.setColour(state.colour); + if (state.x !== undefined || state.y !== undefined) { + note.moveTo(new Blockly.utils.Coordinate(state.x ?? 0, state.y ?? 0)); + } + note.setZIndex(nextZIndex(this.workspace)); + note.restoreMeta({author: this.options.getAuthor()}); + return note; + } finally { + Blockly.Events.setGroup(existingGroup); + } + } + + /** + * @returns Every note on the workspace. + */ + getNotes(): Array { + return this.workspace.getTopComments(false).filter(isNote); + } +} + +export type { + NoteChangeJson, + NoteCopyData, + NoteMeta, + NoteProperty, + NotePropertyValue, + NoteState, + NotesPayload, + PaletteEntry, + SavedNote, + WorkspaceNotesOptions, +} from './types'; +export type {NoteSurface} from './note'; +export {Note, NoteComment, isNote, restackNotes} from './note'; +export {NoteChange} from './events'; +export {NotePaster} from './paster'; +export { + LegacyCommentAdapter, + NoteSerializer, + appendNote, + migrate, + saveNote, +} from './serializer'; +export {domToNote, domToNoteState, noteToDom} from './xml'; +export { + DEFAULT_COLOUR, + DEFAULT_PALETTE, + NOTE_SERIALIZER_NAME, + SCHEMA_VERSION, +} from './constants'; diff --git a/blockly-workspace-notes/src/note.ts b/blockly-workspace-notes/src/note.ts new file mode 100644 index 0000000..e7ef714 --- /dev/null +++ b/blockly-workspace-notes/src/note.ts @@ -0,0 +1,724 @@ +/** + * @fileoverview Notes: workspace comments that also carry a title, a colour, + * authorship metadata and a stacking order. + * + * Blockly keeps its comment model and its rendered comment in a single + * inheritance chain (`WorkspaceComment` -> `RenderedWorkspaceComment`), and a + * headless workspace only ever produces the former. The extra state is + * therefore expressed as a mixin and applied to both, so notes work + * identically in a browser and in a headless workspace (which is all a Node + * test can build — `Blockly.inject` is unavailable there). + */ + +import * as Blockly from 'blockly/core'; + +import { + DEFAULT_COLOUR, + FRAME_RADIUS, + MIN_SIZE, + NOTE_CLASS, + NOTE_MARGIN, + PINNED_CLASS, + RULE_CLASS, + TITLED_CLASS, + TITLE_CLASS, + TITLE_RULE_Y, + TOPBAR_HEIGHT, + UNTITLED_TITLE_TEXT, +} from './constants'; +import {edgeFor} from './colour'; +import {editTitle} from './title_editor'; +import type { + NoteCopyData, + NoteMeta, + NoteProperty, + NotePropertyValue, + NoteState, +} from './types'; +import {NoteChange} from './events'; + +/** + * Any constructor producing a workspace comment. + * + * `any[]` rather than the real parameters because a mixin cannot know what its + * base takes; the two exported classes below restore the real signature. + */ +type CommentConstructor = new ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...args: any[] +) => Blockly.comments.WorkspaceComment; + +/** + * Everything the mixin adds to a workspace comment. + * + * Declared separately from the mixin because TypeScript cannot name an + * anonymous class expression in a `.d.ts`. Without this interface, `declaration: + * true` fails on `NoteComment` and `Note` with "has or is using private name". + */ +export interface NoteSurface { + getNoteState(): NoteState; + getTitle(): string; + setTitle(title: string): void; + getColour(): string; + setColour(colour: string): void; + isPinned(): boolean; + setPinned(pinned: boolean): void; + getZIndex(): number; + setZIndex(zIndex: number): void; + getMeta(): NoteMeta; + restoreMeta(meta: Partial): void; + touchMeta(): void; + saveNoteState(): NoteState; + applyNoteProperty(property: NoteProperty, value?: NotePropertyValue): void; + renderTitle(): void; + renderColour(): void; + applyPinned(): void; + applyZIndex(): void; +} + +/** + * Adds note state and behaviour to a workspace comment class. + * + * @param Base `WorkspaceComment` or `RenderedWorkspaceComment`. + * @returns The extended class. + */ +const NoteMixin = (Base: TBase) => + class extends Base { + /** + * This note's extra state, created on first access. + * + * Declared without an initializer on purpose. `target: es6` keeps + * `useDefineForClassFields` off, so this declaration emits nothing — which + * matters, because a field definition would run after `super()` and wipe a + * value that the base constructor's create event had already caused to be + * built. + */ + protected noteState_?: NoteState; + + /** + * Returns this note's extra state, creating it on first access. + * + * The state is built lazily rather than in a class field because subclass + * fields are initialized only after `super()` returns, and the + * `WorkspaceComment` constructor already fires a create event and drives + * view setup before that point. + * + * @returns The live state object. Treat as read-only. + */ + getNoteState(): NoteState { + if (!this.noteState_) { + const now = new Date().toISOString(); + this.noteState_ = { + title: '', + colour: DEFAULT_COLOUR, + pinned: false, + zIndex: 0, + meta: {author: '', createdAt: now, updatedAt: now}, + }; + } + return this.noteState_; + } + + /** @returns The note's title, or '' if it has none. */ + getTitle(): string { + return this.getNoteState().title; + } + + /** + * Sets the note's title. + * @param title The new title. + */ + setTitle(title: string): void { + this.changeNoteProperty_('title', String(title ?? '')); + } + + /** @returns The note's background colour as a hex string. */ + getColour(): string { + return this.getNoteState().colour; + } + + /** + * Sets the note's background colour. + * @param colour A CSS colour; parsed to hex via Blockly. + */ + setColour(colour: string): void { + const parsed = Blockly.utils.colour.parse(colour) ?? DEFAULT_COLOUR; + this.changeNoteProperty_('colour', parsed); + } + + /** @returns Whether the note is pinned. */ + isPinned(): boolean { + return this.getNoteState().pinned; + } + + /** + * Pins or unpins the note. A pinned note is locked in place and kept in + * front of its neighbours. + * @param pinned Whether the note should be pinned. + */ + setPinned(pinned: boolean): void { + this.changeNoteProperty_('pinned', !!pinned); + } + + /** @returns The note's stacking order; higher is nearer front. */ + getZIndex(): number { + return this.getNoteState().zIndex; + } + + /** + * Sets the note's stacking order. + * @param zIndex The new stacking order. + */ + setZIndex(zIndex: number): void { + this.changeNoteProperty_('zIndex', Number(zIndex) || 0); + } + + /** @returns A copy of the note's metadata. */ + getMeta(): NoteMeta { + return {...this.getNoteState().meta}; + } + + /** + * Replaces the note's metadata wholesale, without firing an event or + * bumping `updatedAt`. Used when loading, so a round-trip preserves + * timestamps verbatim. + * @param meta The metadata to restore. + */ + restoreMeta(meta: Partial): void { + this.getNoteState().meta = {...this.getNoteState().meta, ...meta}; + } + + /** Records that the note changed just now. */ + touchMeta(): void { + this.getNoteState().meta.updatedAt = new Date().toISOString(); + } + + /** + * Returns a plain, JSON-serializable copy of every note-specific field. + * @returns The note's extra state. + */ + saveNoteState(): NoteState { + const state = this.getNoteState(); + return { + title: state.title, + colour: state.colour, + pinned: state.pinned, + zIndex: state.zIndex, + meta: {...state.meta}, + }; + } + + /** + * Applies a property change without firing an event. + * + * This is the single write path: `changeNoteProperty_` uses it for user + * edits, and {@link NoteChange} uses it to replay undo and redo. + * + * @param property The property name, or '*' for a whole state + * object. + * @param value The value to apply. + */ + applyNoteProperty(property: NoteProperty, value?: NotePropertyValue): void { + const state = this.getNoteState(); + // The property name is what says which shape `value` has — a + // correspondence the type system cannot see across two independent + // parameters, so each case asserts the one it knows it has. + switch (property) { + case 'title': + state.title = value as string; + this.renderTitle(); + break; + case 'colour': + state.colour = value as string; + this.renderColour(); + break; + case 'pinned': + state.pinned = value as boolean; + this.applyPinned(); + break; + case 'zIndex': + state.zIndex = value as number; + this.applyZIndex(); + break; + case 'meta': + state.meta = {...(value as NoteMeta)}; + break; + case '*': { + // Null is the "forward" half of a delete snapshot: there is nothing + // to restore when replaying towards the deletion. + if (!value) break; + const whole = value as NoteState; + state.title = whole.title; + state.colour = whole.colour; + state.pinned = whole.pinned; + state.zIndex = whole.zIndex; + state.meta = {...whole.meta}; + this.renderTitle(); + this.renderColour(); + this.applyPinned(); + this.applyZIndex(); + break; + } + default: + console.warn(`Unknown note property: ${property}`); + } + } + + /** + * Reads a single note property. + * @param property The property name, or '*'. + * @returns The current value. + */ + protected readNoteProperty_(property: NoteProperty): NotePropertyValue { + return property === '*' + ? this.saveNoteState() + : this.getNoteState()[property]; + } + + /** + * Applies a change and fires an undoable event describing it. + * @param property The property name. + * @param value The new value. + */ + protected changeNoteProperty_( + property: NoteProperty, + value: NotePropertyValue, + ): void { + const oldValue = this.readNoteProperty_(property); + if (JSON.stringify(oldValue) === JSON.stringify(value)) return; + + this.applyNoteProperty(property, value); + this.touchMeta(); + + if (Blockly.Events.isEnabled()) { + Blockly.Events.fire(new NoteChange(this, property, oldValue, value)); + } + } + + /** Reflects the title in the DOM. Overridden by the rendered subclass. */ + renderTitle(): void {} + + /** Reflects the colour in the DOM. Overridden by the rendered subclass. */ + renderColour(): void {} + + /** Applies the pinned flag. Locking works headlessly too. */ + applyPinned(): void { + this.setMovable(!this.getNoteState().pinned); + } + + /** Applies the stacking order. Overridden by the rendered subclass. */ + applyZIndex(): void {} + + /** + * Disposes of the note. + * + * A snapshot event is fired *before* the delete so that undoing a deletion + * restores the extra fields: a group is undone in reverse, so core's + * `CommentCreate` rebuilds the bare note first and this event then + * repaints it. Core's delete event only carries the fields its own + * serializer knows about. + * + * Both events must share a group, or undo would stop after the first and + * the user would need a second undo to get the colour back. + */ + dispose(): void { + const existingGroup = Blockly.Events.getGroup(); + if (!existingGroup) Blockly.Events.setGroup(true); + try { + if (!this.isDeadOrDying() && Blockly.Events.isEnabled()) { + // newValue is null rather than the state: an event whose two sides + // are equal is dropped by isNull() and never reaches the undo stack. + Blockly.Events.fire( + new NoteChange(this, '*', this.saveNoteState(), null), + ); + } + super.dispose(); + } finally { + Blockly.Events.setGroup(existingGroup); + } + } + }; + +/** + * The mixin applied to the headless comment, with its real constructor. + * + * Naming the result is what makes both of the following work: the constructor + * parameters survive (a mixin's base is `...args: any[]`, which would otherwise + * erase them), and `declaration: true` has a type it can write down instead of + * an anonymous class expression. + */ +const NoteCommentBase = NoteMixin(Blockly.comments.WorkspaceComment) as new ( + workspace: Blockly.Workspace, + id?: string, +) => Blockly.comments.WorkspaceComment & NoteSurface; + +/** + * A note on a headless workspace: all of the state, none of the rendering. + */ +export class NoteComment extends NoteCommentBase {} + +/** + * The mixin applied to the rendered comment. See `NoteCommentBase`. + */ +const RenderedNoteBase = NoteMixin( + Blockly.comments.RenderedWorkspaceComment, +) as new ( + workspace: Blockly.WorkspaceSvg, + id?: string, +) => Blockly.comments.RenderedWorkspaceComment & NoteSurface; + +/** + * A note on a rendered workspace. + */ +export class Note extends RenderedNoteBase { + /** + * The paper card behind the note's chrome. + * + * Every field here is optional, and that is not defensiveness: they are + * assigned after `super()` returns, and `super()` can call back into + * `renderTitle()` and `renderColour()`, which read them. The guard in + * `renderTitle` exists for exactly that window. + */ + private card_?: SVGRectElement | null; + + /** The rule under the title. */ + private rule_?: SVGLineElement; + + /** Where a press on the title started, while one is in progress. */ + private titlePressPoint_?: {x: number; y: number} | null; + + /** The SVG text element holding the title. */ + private titleElement_?: SVGTextElement; + + /** The text node inside `titleElement_`. */ + private titleNode_?: Text; + + /** Watches the comment's size so the chrome can follow it. */ + private sizeObserver_?: MutationObserver; + + /** + * @param workspace The workspace to add the note to. + * @param id An optional ID; generated when omitted. + */ + constructor(workspace: Blockly.WorkspaceSvg, id?: string) { + super(workspace, id); + + const root = this.getSvgRoot(); + Blockly.utils.dom.addClass(root, NOTE_CLASS); + const topBar = root.querySelector('.blocklyCommentTopbar'); + + /** + * The card itself: core's own highlight rect, which a note fills rather + * than drawing paper of its own. Core resizes it on every pointer move of + * a drag and strokes it when the note is selected, so borrowing it keeps + * both for free. + * + * The corners are set once. Core only ever writes height, width and x to + * this rect, so the radii survive every resize. + * @private + */ + this.card_ = root.querySelector('.blocklyCommentHighlight'); + this.card_?.setAttribute('rx', `${FRAME_RADIUS}`); + this.card_?.setAttribute('ry', `${FRAME_RADIUS}`); + + /** + * The hairline under the title. + * + * The heading needs separating from the body, and a box around the body + * is the one thing that cannot do it: a lighter bordered panel inset in a + * coloured body is exactly how Blockly draws a field on a block, so a + * note built that way reads as a block however it is shaped. A rule reads + * as an index card instead. + * @private + */ + this.rule_ = Blockly.utils.dom.createSvgElement(Blockly.utils.Svg.LINE, { + 'class': RULE_CLASS, + }); + if (this.card_) { + root.insertBefore(this.rule_, this.card_.nextSibling); + } else { + root.appendChild(this.rule_); + } + + /** + * Where the pointer went down on the title, so a press that turns into a + * drag can be told apart from a click. + * @private + */ + this.titlePressPoint_ = null; + + /** + * The SVG text element showing the title above the writing area. + * @private + */ + this.titleElement_ = Blockly.utils.dom.createSvgElement( + Blockly.utils.Svg.TEXT, + {'class': `${TITLE_CLASS} blocklyText`}, + topBar, + ); + + /** + * The text node holding the (possibly truncated) title. + * @private + */ + this.titleNode_ = document.createTextNode(''); + this.titleElement_.appendChild(this.titleNode_); + + // A field on a block opens its editor on a single click, and the title + // does the same. Gesture decides a field click by checking the press + // never travelled further than the drag radius, so this repeats that test + // rather than claiming every press: dragging a note by its title has to + // keep working. + // + // Bound directly rather than through browserEvents.conditionalBind, which + // gates on Blockly's own touch handling. + this.titleElement_.addEventListener('pointerdown', (e) => { + this.titlePressPoint_ = {x: e.clientX, y: e.clientY}; + }); + + this.titleElement_.addEventListener('pointerup', (e) => { + const press = this.titlePressPoint_; + this.titlePressPoint_ = null; + if (!press || !this.isEditable()) return; + const travelled = Math.hypot(e.clientX - press.x, e.clientY - press.y); + if (travelled > Blockly.config.dragRadius) return; + + // Deferred by a task, and deliberately not stopped. Core's gesture ends + // on this same release and focuses the note's root, so an editor opened + // inline would have focus taken straight back off it; and Gesture binds + // the release on the document, so stopping the event here would leave + // that gesture running. + setTimeout(() => editTitle(this), 0); + }); + + /** + * Watches the view's own width and height attributes. + * + * Core resizes through `setSizeWithoutFiringEvents` on every pointer move + * of a resize drag and only fires its size listeners once, on pointer up. + * Laying the title out from those listeners alone would leave it + * truncated to the old width for the whole drag, so track the attributes + * core writes instead - they change on every move. + * @private + */ + this.sizeObserver_ = new MutationObserver(() => this.renderChrome()); + this.sizeObserver_.observe(root, { + attributes: true, + attributeFilter: ['width', 'height'], + }); + this.view.addDisposeListener(() => this.sizeObserver_?.disconnect()); + + this.view.addOnCollapseListener(() => this.renderChrome()); + + this.renderColour(); + this.renderChrome(); + + // Every size a note is ever given passes through here: core's resize drag + // calls this on each pointer move, `setSize` calls it, and so does + // collapsing (with the stored size, which is why clamping cannot disturb + // it). Core's own floor is computed in a private method a plugin has no + // business replacing, and it is the wrong floor anyway - see MIN_SIZE. + const setViewSize = this.view.setSizeWithoutFiringEvents.bind(this.view); + this.view.setSizeWithoutFiringEvents = (size: Blockly.utils.Size) => { + setViewSize( + Blockly.utils.Size.max( + size, + new Blockly.utils.Size(MIN_SIZE.width, MIN_SIZE.height), + ), + ); + }; + + // Core sizes the note once from its own constructor, and derives the + // writing area's offset there from the measured height of the top bar + // rect. That measurement happens before `super()` returns, which is + // before this constructor can add NOTE_CLASS - so the rect is still core's + // own 24px bar, and the body is left starting a title row too high, + // overlapping the title and crossing the rule. It corrected itself on the + // first resize, collapse or keystroke, which is what made it look like a + // rendering glitch rather than a wrong number. + // + // The class is on the root by now, so one more size pass measures 48 and + // puts the body under the rule. Without firing events: the note is still + // being constructed, and a size change nobody made does not belong on the + // undo stack. + this.view.setSizeWithoutFiringEvents(this.view.getSize()); + } + + /** + * Redraws everything this plugin lays out over core's comment view: the + * card's height and the title. + */ + renderChrome() { + if (this.isDeadOrDying()) return; + this.renderCard(); + this.renderRule(); + this.renderTitle(); + } + + /** + * Shrinks the card to the top bar when the note is collapsed. + * + * Core leaves its highlight rect at the expanded size whatever the + * collapsed state - `updateHighlightRect` is always passed `this.size` - + * because the rect is `fill: none` for a plain comment and therefore never + * seen. A note fills it, so left alone a collapsed note would sit there as + * a full-height card with an empty body. `view.getSize()` is the + * collapse-aware measurement, so the height is taken from that instead. + */ + renderCard() { + if (!this.card_) return; + this.card_.setAttribute('height', `${this.view.getSize().height}`); + } + + /** + * Stretches the hairline to the width of the note. + * + * It is inset to the title's own gutter rather than running edge to edge, + * so it starts where the heading starts. The stylesheet hides it on a + * collapsed note, where there is no body left to divide it from. + */ + renderRule() { + if (!this.rule_) return; + const {width} = this.view.getSize(); + const dir = this.workspace.RTL ? -1 : 1; + const inset = Math.min(NOTE_MARGIN, width / 2); + this.rule_.setAttribute('x1', `${dir * inset}`); + this.rule_.setAttribute('x2', `${dir * Math.max(inset, width - inset)}`); + this.rule_.setAttribute('y1', `${TITLE_RULE_Y}`); + this.rule_.setAttribute('y2', `${TITLE_RULE_Y}`); + } + + /** + * Paints the note by overriding the CSS custom properties core's comment + * stylesheet already reads, which avoids restyling its elements directly. + * + * Two values off the one stored colour: the paper, and the edge that draws + * both the card's outline and the rule under the title. The text is written + * straight onto the paper, so there is no third. + */ + renderColour() { + const colour = this.getColour(); + const style = this.getSvgRoot().style; + style.setProperty('--commentFillColour', colour); + style.setProperty('--commentBorderColour', edgeFor(colour)); + } + + /** + * Draws the title, truncated to the width of the note. + * + * Called on every size change as well as every title change, since the + * truncation depends on both. + */ + renderTitle() { + if (!this.titleElement_ || this.isDeadOrDying()) return; + + const named = !!this.getTitle(); + Blockly.utils.dom[named ? 'addClass' : 'removeClass']( + this.getSvgRoot(), + TITLED_CLASS, + ); + + // An unnamed note still shows a title row - the placeholder is what says + // the row can be clicked - so there is always text to lay out. The + // stylesheet greys it. + const title = named ? this.getTitle() : UNTITLED_TITLE_TEXT; + if (!this.titleNode_ || !this.titleElement_) return; + this.titleNode_.textContent = title; + + this.titleElement_.setAttribute( + 'x', + `${this.workspace.RTL ? -NOTE_MARGIN : NOTE_MARGIN}`, + ); + this.titleElement_.setAttribute('y', `${TOPBAR_HEIGHT / 2}`); + + // Trim a character at a time; titles are short, so this settles fast. + const maxWidth = Math.max(0, this.view.getSize().width - NOTE_MARGIN * 2); + let text = title; + while ( + text.length > 1 && + Blockly.utils.dom.getTextWidth(this.titleElement_) > maxWidth + ) { + text = text.slice(0, -1); + this.titleNode_.textContent = `${text}\u2026`; + } + } + + /** Locks or unlocks the note, and marks it visually. */ + applyPinned() { + super.applyPinned(); + const pinned = this.isPinned(); + Blockly.utils.dom[pinned ? 'addClass' : 'removeClass']( + this.getSvgRoot(), + PINNED_CLASS, + ); + if (pinned) this.applyZIndex(); + } + + /** Restacks every note on the workspace to match their z-indices. */ + applyZIndex() { + restackNotes(this.workspace); + } + + /** + * Includes the note's extra state in clipboard data so that duplicate and + * paste keep the title and colour. Consumed by NotePaster. + * @returns The copy data, or null if the note is not copyable. + */ + toCopyData(): NoteCopyData | null { + const data = super.toCopyData() as NoteCopyData | null; + if (!data) return null; + data.noteState = this.saveNoteState(); + return data; + } +} + +/** + * Reorders the notes on a workspace so their DOM order matches their + * z-indices. + * + * @param workspace The workspace to restack. + */ +export function restackNotes(workspace: Blockly.Workspace): void { + if (!workspace.rendered) return; + const notes = workspace + .getTopComments(false) + .filter( + (comment): comment is Note => + comment instanceof Note && !comment.isDeadOrDying(), + ); + notes + .sort((a, b) => a.getZIndex() - b.getZIndex()) + .forEach((note) => note.view.bringToFront()); +} + +/** + * @param workspace The workspace to inspect. + * @returns One more than the highest z-index in use. + */ +export function nextZIndex(workspace: Blockly.Workspace): number { + const zIndices = workspace + .getTopComments(false) + .filter(isNote) + .map((note) => note.getZIndex()); + return zIndices.length ? Math.max(...zIndices) + 1 : 1; +} + +/** + * @param workspace The workspace to inspect. + * @returns One less than the lowest z-index in use. + */ +export function previousZIndex(workspace: Blockly.Workspace): number { + const zIndices = workspace + .getTopComments(false) + .filter(isNote) + .map((note) => note.getZIndex()); + return zIndices.length ? Math.min(...zIndices) - 1 : -1; +} + +/** + * @param candidate Any value. + * @returns Whether the value is a note. + */ +export function isNote(candidate: unknown): candidate is Note | NoteComment { + return candidate instanceof Note || candidate instanceof NoteComment; +} diff --git a/blockly-workspace-notes/src/paster.ts b/blockly-workspace-notes/src/paster.ts new file mode 100644 index 0000000..66befb3 --- /dev/null +++ b/blockly-workspace-notes/src/paster.ts @@ -0,0 +1,140 @@ +/** + * @fileoverview Clipboard support for notes. + * + * Core's paster rebuilds a comment through its own serializer, which knows + * nothing about titles or colours — so a copy/paste or duplicate would return + * a plain yellow note. This replaces it under the same paster type, since that + * is the type name `RenderedWorkspaceComment.toCopyData()` stamps onto the + * clipboard data. + */ + +import * as Blockly from 'blockly/core'; + +import type {NoteCopyData, SavedNote} from './types'; +import {Note} from './note'; +import {appendNote} from './serializer'; + +/** + * The paster type core registers for workspace comments. Matching it means + * both notes and any plain comments still route here. + */ +export const PASTER_TYPE = 'workspace-comment'; + +/** How far to nudge a pasted note that would land exactly on another. */ +const OFFSET_DISTANCE = 30; + +/** + * Pastes notes, preserving their note-specific state. + */ +export class NotePaster implements Blockly.IPaster { + /** + * @param copyData Data produced by `Note.toCopyData()`. + * @param workspace The workspace to paste into. + * @param [coordinate] Where to paste. + * @returns The pasted note, or null. + */ + paste( + copyData: NoteCopyData, + workspace: Blockly.WorkspaceSvg, + coordinate?: Blockly.utils.Coordinate, + ): Note | null { + const state = { + ...copyData.commentState, + ...(copyData.noteState ?? {}), + } as SavedNote; + + if (coordinate) { + state.x = coordinate.x; + state.y = coordinate.y; + } + + // Core builds the note silently and fires a single create event + // afterwards, so a paste is one undo step rather than a dozen. + Blockly.Events.disable(); + let note: Note | null; + try { + const created = appendNote(state, workspace); + // `workspace` is rendered, so `appendNote` took the rendered branch — + // and only a rendered note can be focused or nudged. + note = created instanceof Note ? created : null; + if (note) moveOutOfTheWay(note); + } finally { + Blockly.Events.enable(); + } + + if (!note) return null; + + if (Blockly.Events.isEnabled()) { + Blockly.Events.fire(new Blockly.Events.CommentCreate(note)); + } + Blockly.getFocusManager().focusNode(note); + return note; + } +} + +/** + * Nudges a note diagonally until it no longer sits exactly on top of another. + * + * @param note The freshly pasted note. + */ +function moveOutOfTheWay(note: Note): void { + const workspace = note.workspace; + const coordinate = note.getRelativeToSurfaceXY(); + const offset = new Blockly.utils.Coordinate(0, 0); + const others = workspace + .getTopComments(false) + .filter((other) => other.id !== note.id) + .map((other) => other.getRelativeToSurfaceXY()); + + const overlaps = (candidate: Blockly.utils.Coordinate): boolean => + others.some( + (other) => + Math.abs(other.x - candidate.x) <= 1 && + Math.abs(other.y - candidate.y) <= 1, + ); + + while (overlaps(Blockly.utils.Coordinate.sum(coordinate, offset))) { + offset.translate( + workspace.RTL ? -OFFSET_DISTANCE : OFFSET_DISTANCE, + OFFSET_DISTANCE, + ); + } + + note.moveTo(Blockly.utils.Coordinate.sum(coordinate, offset)); +} + +/** Reference count, since the clipboard registry is a global singleton. */ +let registrationCount = 0; + +/** + * The paster displaced on first registration, kept so it can be put back + * verbatim. `WorkspaceCommentPaster` is not exported on the Blockly + * namespace, so it cannot simply be reconstructed. + */ +let displacedPaster: Blockly.IPaster< + Blockly.ICopyData, + Blockly.ICopyable +> | null = null; + +/** Replaces core's workspace comment paster with the note-aware one. */ +export function registerNotePaster(): void { + if (registrationCount++) return; + displacedPaster = Blockly.registry.getObject( + Blockly.registry.Type.PASTER, + PASTER_TYPE, + false, + ); + Blockly.clipboard.registry.unregister(PASTER_TYPE); + Blockly.clipboard.registry.register(PASTER_TYPE, new NotePaster()); +} + +/** Restores core's workspace comment paster. */ +export function unregisterNotePaster(): void { + if (--registrationCount > 0) return; + registrationCount = 0; + Blockly.clipboard.registry.unregister(PASTER_TYPE); + if (displacedPaster) { + Blockly.clipboard.registry.register(PASTER_TYPE, displacedPaster); + displacedPaster = null; + } +} diff --git a/blockly-workspace-notes/src/serializer.ts b/blockly-workspace-notes/src/serializer.ts new file mode 100644 index 0000000..03ae3a6 --- /dev/null +++ b/blockly-workspace-notes/src/serializer.ts @@ -0,0 +1,407 @@ +/** + * @fileoverview JSON serialization for workspace notes. + * + * Notes ride along in the same payload as blocks and variables, under their + * own top-level `workspaceNotes` key: + * + * { + * "blocks": {...}, + * "workspaceNotes": {"version": 1, "notes": [...]} + * } + * + * A note is a `RenderedWorkspaceComment`, so it also lands in + * `workspace.getTopComments()` and Blockly's built-in comment serializer would + * save it a second time — reloading would then produce two notes for every + * one. The plugin therefore replaces that serializer with an adapter that + * writes nothing and only exists to keep older `workspaceComments` files + * loadable. + */ + +import * as Blockly from 'blockly/core'; + +import { + COMMENT_SERIALIZER_NAME, + DEFAULT_COLOUR, + NOTE_SERIALIZER_NAME, + SCHEMA_VERSION, +} from './constants'; +import type {NotesPayload, SavedNote, WorkspaceNotesOptions} from './types'; +import {Note, NoteComment, isNote, restackNotes} from './note'; + +/** + * Constructs the right note class for a workspace. A headless workspace + * cannot hold a rendered note, and that is the only kind a Node test can make. + * + * @param workspace The workspace to create the note on. + * @param [id] An optional ID. + * @returns The new note. + */ +export function createNote( + workspace: Blockly.Workspace, + id?: string, +): Note | NoteComment { + // `rendered` is what distinguishes the two at runtime; the compiler cannot + // narrow a base Workspace from a boolean flag. + return workspace.rendered + ? new Note(workspace as Blockly.WorkspaceSvg, id) + : new NoteComment(workspace, id); +} + +/** + * Serializes a single note. + * + * Sparse by design, matching core's comment serializer: width and height are + * always written, and everything else only when it differs from the default. + * That keeps saved files small and diffable. + * + * @param note The note to save. + * @param options What to include beyond the note's own fields. + * @param options.addCoordinates Whether to write the note's position. + * @param options.saveIds Whether to write the note's id. + * @returns The note's JSON state. + */ +export function saveNote( + note: Note | NoteComment, + {addCoordinates = false, saveIds = false} = {}, +): SavedNote { + const workspace = note.workspace; + const state: SavedNote = {} as SavedNote; + + state.height = note.getSize().height; + state.width = note.getSize().width; + + if (saveIds) state.id = note.id; + + if (addCoordinates) { + const loc = note.getRelativeToSurfaceXY(); + state.x = workspace.RTL ? workspace.getWidth() - loc.x : loc.x; + state.y = loc.y; + } + + if (note.getText()) state.text = note.getText(); + if (note.isCollapsed()) state.collapsed = true; + + // `isOwn*` rather than `is*`: a read-only *workspace* must not poison the + // per-note flags we persist. + if (!note.isOwnEditable()) state.editable = false; + if (!note.isOwnDeletable()) state.deletable = false; + + const pinned = typeof note.isPinned === 'function' && note.isPinned(); + // A pinned note is immovable by definition, so `movable` would be noise. + if (!note.isOwnMovable() && !pinned) state.movable = false; + + // Note-specific fields. A plain comment created outside the plugin has + // none of these accessors; it simply serializes without them. + if (typeof note.getTitle !== 'function') return state; + + if (note.getTitle()) state.title = note.getTitle(); + // Case-insensitive: `setColour` normalizes through Blockly's parser, but a + // caller could have written an uppercase hex straight into the state. + if (note.getColour().toLowerCase() !== DEFAULT_COLOUR) { + state.colour = note.getColour(); + } + if (pinned) state.pinned = true; + if (note.getZIndex()) state.zIndex = note.getZIndex(); + + const meta = note.getMeta(); + if (Object.values(meta).some((value) => value)) state.meta = meta; + + return state; +} + +/** + * Creates a note on a workspace from its JSON state. + * + * @param state The note state to load. + * @param workspace The workspace to add the note to. + * @param [options] Whether the resulting events + * should be undoable. + * @param options.recordUndo Whether the append should be undoable. + * @returns The created note. + */ +export function appendNote( + state: SavedNote, + workspace: Blockly.Workspace, + {recordUndo = false} = {}, +): Note | NoteComment { + const previousRecordUndo = Blockly.Events.getRecordUndo(); + Blockly.Events.setRecordUndo(recordUndo); + const existingGroup = Blockly.Events.getGroup(); + if (!existingGroup) Blockly.Events.setGroup(true); + + let note; + try { + note = createNote(workspace, state.id); + + if (state.text !== undefined) note.setText(state.text); + + if (state.x !== undefined || state.y !== undefined) { + const rawX = state.x ?? 0; + const x = workspace.RTL ? workspace.getWidth() - rawX : rawX; + note.moveTo(new Blockly.utils.Coordinate(x, state.y ?? 0)); + } + + if (state.width !== undefined || state.height !== undefined) { + note.setSize(new Blockly.utils.Size(state.width ?? 0, state.height ?? 0)); + } + + if (state.collapsed !== undefined) { + note.setCollapsed(state.collapsed as boolean); + } + if (state.editable !== undefined) + note.setEditable(state.editable as boolean); + if (state.movable !== undefined) note.setMovable(state.movable as boolean); + if (state.deletable !== undefined) { + note.setDeletable(state.deletable as boolean); + } + + if (typeof note.setTitle === 'function') { + if (state.colour !== undefined) note.setColour(state.colour); + if (state.title !== undefined) note.setTitle(state.title); + if (state.zIndex !== undefined) note.setZIndex(state.zIndex); + // Applied after `movable`, which it overrides. + if (state.pinned) note.setPinned(true); + // Last, and deliberately not through a setter: restoring metadata must + // not stamp a fresh `updatedAt` over the one we just loaded. + if (state.meta) note.restoreMeta(state.meta); + } + } finally { + Blockly.Events.setGroup(existingGroup); + Blockly.Events.setRecordUndo(previousRecordUndo); + } + + return note; +} + +/** + * Upgrades an older payload to the current schema. + * + * Keyed by the version being upgraded *from*; each entry returns a payload at + * the next version. + */ +const MIGRATIONS: Record NotesPayload> = { + // v0 is the unversioned shape: a bare array of note states, which is also + // what a legacy `workspaceComments` list looks like. + 0: (state) => ({version: 1, notes: state.notes ?? []}), +}; + +/** + * Normalizes and upgrades a `workspaceNotes` payload. + * + * @param state The raw payload. + * @returns A payload at the current schema version. + */ +export function migrate( + state: NotesPayload | SavedNote[] | undefined, +): NotesPayload { + let current: NotesPayload = Array.isArray(state) + ? {version: 0, notes: state} + : {version: Number(state?.version) || 0, notes: state?.notes ?? []}; + + if (current.version > SCHEMA_VERSION) { + console.warn( + `Loading workspaceNotes v${current.version} with a plugin that ` + + `understands v${SCHEMA_VERSION}; unknown fields will be dropped.`, + ); + return {version: SCHEMA_VERSION, notes: current.notes}; + } + + while (current.version < SCHEMA_VERSION) { + const step = MIGRATIONS[current.version]; + if (!step) break; + const next = step(current); + // Guard against a migration that fails to advance the version, which + // would otherwise spin forever. + if ((Number(next.version) || 0) <= current.version) break; + current = next; + } + + return current; +} + +/** + * Saves and loads notes under the `workspaceNotes` key. + * + * @implements {Blockly.serialization.ISerializer} + */ +export class NoteSerializer implements Blockly.serialization.ISerializer { + /** + * Ordered just above core's comment priority so that notes load before the + * legacy adapter runs and it can skip anything already present. Equal + * priorities are ordered arbitrarily relative to each other, so the two must + * differ. + */ + priority = Blockly.serialization.priorities.WORKSPACE_COMMENTS + 1; + + /** + * @param workspace The workspace to serialize. + * @returns The notes payload, or null when there are none. + */ + save(workspace: Blockly.Workspace): NotesPayload | null { + const notes = workspace + .getTopComments(false) + .filter(isNote) + .map((note) => saveNote(note, {addCoordinates: true, saveIds: true})); + // Returning null omits the key entirely; an empty object would be falsy + // to `workspaces.load` anyway and is never worth writing. + return notes.length ? {version: SCHEMA_VERSION, notes} : null; + } + + /** + * @param state The notes payload. + * @param workspace The workspace to load into. + */ + load(state: object, workspace: Blockly.Workspace): void { + const recordUndo = Blockly.Events.getRecordUndo(); + for (const noteState of migrate(state as NotesPayload).notes) { + appendNote(noteState, workspace, {recordUndo}); + } + restackNotes(workspace); + } + + /** + * Disposes of every comment on the workspace. + * + * This owns clearing for both keys: the legacy adapter deliberately does + * nothing so that nothing is disposed twice. Non-note comments are included, + * matching what core's serializer did before we replaced it — otherwise they + * would survive a load and accumulate. + * + * @param workspace The workspace to clear. + */ + clear(workspace: Blockly.Workspace): void { + for (const comment of workspace.getTopComments(false)) { + comment.dispose(); + } + } +} + +/** + * Stands in for Blockly's built-in comment serializer. + * + * Writes nothing — notes are the single source of truth — but still reads + * `workspaceComments`, so files saved before this plugin (or by plain Blockly) + * load as notes. + * + * @implements {Blockly.serialization.ISerializer} + */ +export class LegacyCommentAdapter implements Blockly.serialization.ISerializer { + /** + * @param [options] Set + * `emitLegacyComments` to keep writing the old key for a host that still + * reads it. Off by default, since it duplicates every note. + */ + /** Whether to keep writing the old workspaceComments key. */ + private emitLegacyComments_: boolean; + + /** Runs alongside core's own comment serializer. */ + priority: number; + + /** + * @param options Whether to keep emitting legacy comments. + * @param options.emitLegacyComments Whether to write the old key too. + */ + constructor({emitLegacyComments = false}: WorkspaceNotesOptions = {}) { + /** @type {number} */ + this.priority = Blockly.serialization.priorities.WORKSPACE_COMMENTS; + + /** + * @private + */ + this.emitLegacyComments_ = emitLegacyComments; + } + + /** + * @param workspace The workspace to serialize. + * @returns Legacy comment states, or null. + */ + save(workspace: Blockly.Workspace): SavedNote[] | null { + if (!this.emitLegacyComments_) return null; + const comments = workspace + .getTopComments(false) + .filter(isNote) + .map((note) => saveNote(note, {addCoordinates: true, saveIds: true})); + return comments.length ? comments : null; + } + + /** + * @param state Legacy comment states. + * @param workspace The workspace to load into. + */ + load(state: object, workspace: Blockly.Workspace): void { + const recordUndo = Blockly.Events.getRecordUndo(); + // The interface types this as `object`; core passes the array core wrote. + for (const commentState of state as SavedNote[]) { + // Notes load first (higher priority). If a file somehow carries both + // keys, the note wins rather than being duplicated under a fresh ID. + if (commentState.id && workspace.getCommentById(commentState.id)) { + continue; + } + appendNote(commentState, workspace, {recordUndo}); + } + } + + /** + * Intentionally empty; NoteSerializer.clear disposes every comment. + */ + clear(): void {} +} + +/** + * Whether this module has swapped the serializers in yet. The registry is a + * global singleton but the plugin is per-workspace, so registration is + * reference-counted. + */ +let registrationCount = 0; + +/** + * The comment serializer displaced on first registration, so it can be put + * back exactly as it was. + */ +let displacedCommentSerializer: Blockly.serialization.ISerializer | null = null; + +/** + * Registers the note serializer and replaces Blockly's comment serializer. + * + * @param [options] Serializer options, + * honoured on the first registration. + */ +export function registerNoteSerializers( + options: WorkspaceNotesOptions = {}, +): void { + if (registrationCount++) return; + + displacedCommentSerializer = Blockly.registry.getObject( + Blockly.registry.Type.SERIALIZER, + COMMENT_SERIALIZER_NAME, + false, + ); + + // `register` throws on a duplicate name, so the built-in must go first. + Blockly.serialization.registry.unregister(COMMENT_SERIALIZER_NAME); + Blockly.serialization.registry.register( + COMMENT_SERIALIZER_NAME, + new LegacyCommentAdapter(options), + ); + Blockly.serialization.registry.register( + NOTE_SERIALIZER_NAME, + new NoteSerializer(), + ); +} + +/** + * Restores Blockly's built-in comment serializer. + */ +export function unregisterNoteSerializers(): void { + if (--registrationCount > 0) return; + registrationCount = 0; + + Blockly.serialization.registry.unregister(NOTE_SERIALIZER_NAME); + Blockly.serialization.registry.unregister(COMMENT_SERIALIZER_NAME); + Blockly.serialization.registry.register( + COMMENT_SERIALIZER_NAME, + displacedCommentSerializer ?? + new Blockly.serialization.workspaceComments.WorkspaceCommentSerializer(), + ); + displacedCommentSerializer = null; +} diff --git a/blockly-workspace-notes/src/title_editor.ts b/blockly-workspace-notes/src/title_editor.ts new file mode 100644 index 0000000..bb4dbfe --- /dev/null +++ b/blockly-workspace-notes/src/title_editor.ts @@ -0,0 +1,171 @@ +/** + * @fileoverview Editing a note's title in place. + * + * Blockly never asks for text in a dialog. A field on a block is edited where + * it sits: `FieldInput` puts an `` inside + * `WidgetDiv`, positions it over the field, and commits on Enter or blur. + * This does the same over a note's title. + * + * The difference from a field is that none of it should be visible. A field + * editor is a white box that opens over the block, which on a note reads as a + * mode rather than as typing; the stylesheet strips the box, this places the + * editor exactly over the title, and the caret goes to the end rather than + * selecting the line. The effect is that the heading simply becomes typeable, + * the way the body below it always is. + * + * The alternative, `Blockly.dialog.prompt`, falls back to the browser's own + * `window.prompt` unless the host has replaced it — a serif system dialog + * that matches nothing else on the page. + */ + +import * as Blockly from 'blockly/core'; + +import type {Note} from './note'; +import { + NOTE_MARGIN, + TITLE_FONT_SIZE, + TITLE_LINE_HEIGHT, + UNTITLED_TITLE_TEXT, +} from './constants'; + +/** + * Where the editor should sit, in viewport pixels. + */ +interface EditorBox { + left: number; + top: number; + width: number; + height: number; +} + +/** + * Returns where the editor should sit, in screen pixels. + * + * Taken from the title's own client rect, so the editor lands exactly over + * the text it replaces whatever the workspace scale and scroll are. Every + * note has a title row — an unnamed one shows a placeholder — so there is + * always something to measure. + * + * @param note The note being renamed. + * @returns A DOMRect-like box, or null if nothing is rendered. + */ +function editorBox(note: Note): EditorBox | null { + const title = note.getSvgRoot().querySelector('.blocklyNoteTitle'); + const box = title?.getBoundingClientRect(); + if (!box?.width) return null; + + // Left and top come straight from the title, so the text does not shift by + // a pixel when the editor opens over it. + const scale = note.workspace.getAbsoluteScale(); + // Room to type past the end of the current title, but never past the paper: + // the editor is transparent, so anything overflowing would be text floating + // on the canvas. + const room = + note.getSvgRoot().getBoundingClientRect().right - + box.left - + NOTE_MARGIN * scale; + return { + left: box.left, + top: box.top, + width: Math.max( + Math.min(Math.max(box.width * 2, TITLE_LINE_HEIGHT * 6 * scale), room), + box.width, + ), + height: box.height, + }; +} + +/** + * Opens an inline editor over a note's title. + * + * Enter or a click elsewhere commits; Escape restores the original title. The + * whole edit is one undo step. + * + * @param note The note to rename. + */ +export function editTitle(note: Note): void { + if (!note.workspace?.rendered || note.isDeadOrDying()) return; + + const box = editorBox(note); + if (!box) return; + + let cancelled = false; + let input: HTMLInputElement | null = null; + + const commit = () => { + if (cancelled || !input) return; + const existingGroup = Blockly.Events.getGroup(); + if (!existingGroup) Blockly.Events.setGroup(true); + try { + note.setTitle(input.value); + } finally { + Blockly.Events.setGroup(existingGroup); + } + }; + + Blockly.WidgetDiv.show( + note, + note.workspace.RTL, + () => { + commit(); + Blockly.utils.dom.removeClass(note.getSvgRoot(), 'blocklyEditing'); + }, + note.workspace, + ); + + const div = Blockly.WidgetDiv.getDiv(); + if (!div) return; + + // The heading's own size, not the field size core uses for its editors: the + // editor is meant to be invisible, and text that changes size the moment a + // caret lands in it is the most visible thing a field editor can do. Scaled + // like every other on-canvas measurement, since the widget div is in page + // pixels while the title is in workspace units. + const fontSize = `${TITLE_FONT_SIZE * note.workspace.getAbsoluteScale()}px`; + div.style.fontSize = fontSize; + div.style.width = `${box.width}px`; + div.style.height = `${box.height}px`; + + // WidgetDiv is positioned relative to its parent, not to the page. + const parent = div.parentElement?.getBoundingClientRect(); + div.style.left = `${box.left - (parent?.left ?? 0) - window.scrollX}px`; + div.style.top = `${box.top - (parent?.top ?? 0) - window.scrollY}px`; + + input = document.createElement('input'); + // Blockly's own class, so the editor inherits the field styling core + // already ships rather than inventing a second look. + input.className = 'blocklyHtmlInput blocklyNoteTitleInput'; + input.setAttribute('spellcheck', 'false'); + input.setAttribute('aria-label', 'Note title'); + // The same greyed placeholder the note itself shows, so opening the editor + // on an unnamed note changes nothing on screen but the caret. + input.setAttribute('placeholder', UNTITLED_TITLE_TEXT); + input.style.fontSize = fontSize; + // Inline, like the size above, and for a sharper reason: the renderer ships + // .thrasos-renderer.classic-theme .blocklyHtmlInput { font-weight: normal }, + // three classes that outrank any single-class rule the stylesheet can give + // .blocklyNoteTitleInput. Left to CSS the heading would drop to book weight + // the instant the caret landed in it. + input.style.fontWeight = 'bold'; + input.value = note.getTitle(); + div.appendChild(input); + + Blockly.utils.dom.addClass(note.getSvgRoot(), 'blocklyEditing'); + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.stopPropagation(); + Blockly.WidgetDiv.hide(); + } else if (e.key === 'Escape') { + e.stopPropagation(); + cancelled = true; + Blockly.WidgetDiv.hide(); + } + }); + + input.focus({preventScroll: true}); + // The caret goes to the end rather than selecting the line: a selected title + // is the visual cue of a field editor opening, which is the thing this is + // trying not to look like. Select-all is still one keystroke away. + input.setSelectionRange(input.value.length, input.value.length); +} diff --git a/blockly-workspace-notes/src/types.ts b/blockly-workspace-notes/src/types.ts new file mode 100644 index 0000000..6a04522 --- /dev/null +++ b/blockly-workspace-notes/src/types.ts @@ -0,0 +1,133 @@ +/** + * @fileoverview The shapes that travel between modules. + * + * In the JavaScript version most of these were `{!Object}` — one annotation + * standing in for a note, a note's saved state, a JSON payload, clipboard data + * and an options bag. The state record is the one worth naming most: it is a + * persisted format, written to save files and read back by `migrate`, so it + * outlives any single release and belongs in one place rather than being + * re-derived wherever it is touched. + */ + +import type * as Blockly from 'blockly/core'; + +/** + * Who made a note and when. + * + * The timestamps are ISO 8601 strings rather than `Date` objects because they + * are written to JSON and read back verbatim. + */ +export interface NoteMeta { + author: string; + createdAt: string; + updatedAt: string; +} + +/** + * Everything a note carries beyond what a workspace comment already has. + */ +export interface NoteState { + title: string; + colour: string; + pinned: boolean; + zIndex: number; + meta: NoteMeta; +} + +/** + * One entry in a note palette. + * + * `hue` is what the picker sorts and derives from; `fill` is the resolved hex + * so the swatch does not have to recompute it. + */ +export interface PaletteEntry { + name: string; + hue: number; + fill: string; +} + +/** + * A note's serialized form, as it appears in a save file. + * + * Deliberately sparse: `saveNote` writes width and height always and everything + * else only when it differs from the default, so most notes serialize to a + * handful of keys. + */ +export interface SavedNote { + width: number; + height: number; + x?: number; + y?: number; + id?: string; + text?: string; + title?: string; + colour?: string; + pinned?: boolean; + zIndex?: number; + meta?: Partial; + [key: string]: unknown; +} + +/** + * The plugin's own slice of a save file, under the `workspaceNotes` key. + */ +export interface NotesPayload { + version: number; + notes: SavedNote[]; +} + +/** + * Which properties `applyNoteProperty` understands. + * + * `'*'` means "replace the whole state", which is how a paste and an undo + * restore a note in one step. + */ +export type NoteProperty = + 'title' | 'colour' | 'pinned' | 'zIndex' | 'meta' | '*'; + +/** + * The value that goes with each `NoteProperty`. + * + * This is what the `{*}` annotation stood in for: the switch in + * `applyNoteProperty` writes into a string slot, a boolean slot, a number slot, + * an object slot and a whole-state slot, and only the property name says which. + */ +export type NotePropertyValue = + string | boolean | number | Partial | NoteState | null; + +/** + * Everything `WorkspaceNotes` accepts. + */ +export interface WorkspaceNotesOptions { + palette?: PaletteEntry[]; + defaultSize?: {width: number; height: number}; + getAuthor?: () => string; + contextMenu?: boolean; + skipSerializerRegistration?: boolean; + emitLegacyComments?: boolean; + xmlSupport?: boolean; +} + +/** + * Clipboard data for a note. + * + * Core's `WorkspaceCommentCopyData` carries only `commentState`; a note adds + * its own half so a pasted note keeps its title and colour. + */ +export interface NoteCopyData { + paster: string; + commentState: {[key: string]: unknown}; + noteState?: NoteState; +} + +/** + * The JSON shape of a `NoteChange` event. + * + * Core's `CommentBaseJson` declares only `commentId`, so the three fields this + * event adds need declaring for `toJson` to be assignable to the base. + */ +export interface NoteChangeJson extends Blockly.Events.CommentBaseJson { + property?: NoteProperty; + oldValue?: NotePropertyValue; + newValue?: NotePropertyValue; +} diff --git a/blockly-workspace-notes/src/xml.ts b/blockly-workspace-notes/src/xml.ts new file mode 100644 index 0000000..50bdd86 --- /dev/null +++ b/blockly-workspace-notes/src/xml.ts @@ -0,0 +1,325 @@ +/** + * @fileoverview XML serialization for notes, for hosts still on the older + * `Blockly.Xml` API. + * + * Blockly 13 still writes and reads workspace comments as XML, so without + * this a note saved through `Blockly.Xml.workspaceToDom` would silently lose + * its title, colour, pinned state and metadata, and would load back as a + * plain comment — `Xml.loadWorkspaceComment` hardcodes + * `new RenderedWorkspaceComment(...)` instead of going through + * `workspace.newComment()`, so the plugin's usual hook does not reach it. + * + * There is no registry for XML the way there is for JSON serializers, so the + * entry points on the `Blockly.Xml` namespace are wrapped instead. Each has to + * be wrapped individually: Blockly's own `appendDomToWorkspace` and + * `clearWorkspaceAndLoadFromXml` call `domToWorkspace` through a module-local + * binding, so patching that one export does not reach them. + * + * Everything here funnels through the same state objects the JSON serializer + * uses, so the two formats cannot drift apart. + */ + +import * as Blockly from 'blockly/core'; + +import type {SavedNote} from './types'; +import {Note, NoteComment, isNote} from './note'; +import {appendNote, saveNote} from './serializer'; + +/** The entry points wrapped on the Blockly.Xml namespace. */ +const PATCHED = [ + 'workspaceToDom', + 'domToWorkspace', + 'appendDomToWorkspace', + 'clearWorkspaceAndLoadFromXml', + 'saveWorkspaceComment', + 'loadWorkspaceComment', +] as const; + +/** One of the six names above. */ +type PatchedName = (typeof PATCHED)[number]; + +/** The six entry points, as a writable record. */ +type PatchedXml = {-readonly [K in PatchedName]: (typeof Blockly.Xml)[K]}; + +/** + * `Blockly.Xml` is an ES module namespace object, so TypeScript treats its + * members as read-only. Replacing them is exactly what this module does — + * there is no registry for XML the way there is for JSON serializers — so the + * namespace is viewed through one mutable alias, declared once, here. Each + * assignment below is still checked against Blockly's real signature. + */ +const Xml = Blockly.Xml as typeof Blockly.Xml & PatchedXml; + +/** + * Converts a note's JSON state into attributes on a `` element. + * + * Only the note-specific fields are written; core has already supplied + * `id`/`x`/`y`/`w`/`h` and the text content. Everything added here is + * optional, so older Blockly reads the element as an ordinary comment and + * simply ignores what it does not recognise. + * + * @param elem The `` element to decorate. + * @param state The note state, as produced by `saveNote`. + */ +function decorateElement(elem: Element, state: SavedNote): void { + if (state.title) elem.setAttribute('title', state.title); + if (state.colour) elem.setAttribute('colour', state.colour); + if (state.pinned) elem.setAttribute('pinned', 'true'); + // `z` rather than `zIndex`, grouping it with core's terse x/y/w/h geometry. + if (state.zIndex) elem.setAttribute('z', `${state.zIndex}`); + if (state.meta?.author) elem.setAttribute('author', state.meta.author); + if (state.meta?.createdAt) elem.setAttribute('created', state.meta.createdAt); + if (state.meta?.updatedAt) elem.setAttribute('updated', state.meta.updatedAt); +} + +/** + * Reads a `` element into the state shape the JSON loader uses. + * + * Mirrors core's `loadWorkspaceComment` for the shared attributes, including + * its `isNaN` guards, so a malformed value is skipped rather than written as + * NaN. The RTL flip is left to `appendNote`, which already applies it. + * + * @param elem A `` element. + * @returns The note state. + */ +export function domToNoteState(elem: Element): SavedNote { + const state: SavedNote = {} as SavedNote; + + const id = elem.getAttribute('id'); + if (id) state.id = id; + + const x = parseInt(elem.getAttribute('x') ?? '', 10); + const y = parseInt(elem.getAttribute('y') ?? '', 10); + if (!isNaN(x) && !isNaN(y)) { + state.x = x; + state.y = y; + } + + const width = parseInt(elem.getAttribute('w') ?? '', 10); + const height = parseInt(elem.getAttribute('h') ?? '', 10); + if (!isNaN(width) && !isNaN(height)) { + state.width = width; + state.height = height; + } + + if (elem.textContent) state.text = elem.textContent; + if (elem.getAttribute('collapsed') === 'true') state.collapsed = true; + if (elem.getAttribute('editable') === 'false') state.editable = false; + if (elem.getAttribute('movable') === 'false') state.movable = false; + if (elem.getAttribute('deletable') === 'false') state.deletable = false; + + const title = elem.getAttribute('title'); + if (title) state.title = title; + + const colour = elem.getAttribute('colour'); + if (colour) state.colour = colour; + + if (elem.getAttribute('pinned') === 'true') state.pinned = true; + + const zIndex = parseInt(elem.getAttribute('z') ?? '', 10); + if (!isNaN(zIndex)) state.zIndex = zIndex; + + const meta = { + author: elem.getAttribute('author') ?? '', + createdAt: elem.getAttribute('created') ?? '', + updatedAt: elem.getAttribute('updated') ?? '', + }; + if (Object.values(meta).some((value) => value)) state.meta = meta; + + return state; +} + +/** + * Serializes a note to a `` element. + * + * @param note The note to save. + * @param [skipId] True to omit the note's ID. + * @returns The `` element. + */ +export function noteToDom(note: Note | NoteComment, skipId = false): Element { + const elem = Blockly.utils.xml.createElement('comment'); + const state = saveNote(note, {addCoordinates: true, saveIds: !skipId}); + + if (state.id) elem.setAttribute('id', state.id); + elem.setAttribute('x', `${state.x}`); + elem.setAttribute('y', `${state.y}`); + elem.setAttribute('w', `${state.width}`); + elem.setAttribute('h', `${state.height}`); + + if (state.text) elem.textContent = state.text; + if (state.collapsed) elem.setAttribute('collapsed', 'true'); + if (state.editable === false) elem.setAttribute('editable', 'false'); + if (state.movable === false) elem.setAttribute('movable', 'false'); + if (state.deletable === false) elem.setAttribute('deletable', 'false'); + + decorateElement(elem, state); + return elem; +} + +/** + * Loads a `` element as a note. + * + * @param elem A `` element. + * @param workspace The workspace to load into. + * @returns The created note. + */ +export function domToNote( + elem: Element, + workspace: Blockly.Workspace, +): Note | NoteComment { + return appendNote(domToNoteState(elem), workspace, { + // Core's XML loader does not force recordUndo the way the JSON loader + // does, so honour whatever the caller has set. + recordUndo: Blockly.Events.getRecordUndo(), + }); +} + +/** + * @param xml An `` element. + * @returns Its direct `` children. Block comments + * are nested inside `` and so are untouched. + */ +function topLevelComments(xml: Element): Element[] { + return Array.from(xml.childNodes).filter( + (node): node is Element => node.nodeName.toLowerCase() === 'comment', + ); +} + +/** + * Returns a copy of an `` element with its top-level comments removed, + * alongside the removed elements. + * + * Loading is delegated to Blockly for everything except comments, and the + * notes are created afterwards. The caller's DOM is cloned rather than + * mutated, since callers commonly reuse the same document. + * + * @param xml An `` element. + * @returns The split. + */ +function splitComments(xml: Element): {stripped: Element; comments: Element[]} { + const stripped = xml.cloneNode(true) as Element; + const comments = topLevelComments(stripped); + for (const elem of comments) stripped.removeChild(elem); + return {stripped, comments}; +} + +/** The original Blockly.Xml functions, kept so they can be restored. */ +let originals: PatchedXml | null = null; + +/** + * Reference count, since Blockly.Xml is global but the plugin is per + * workspace: the wrappers stay in place until the last instance goes away. + */ +let registrationCount = 0; + +/** + * Wraps the Blockly.Xml entry points so notes survive an XML round trip. + */ +export function registerXmlSupport(): void { + if (registrationCount++) return; + + originals = { + workspaceToDom: Xml.workspaceToDom, + domToWorkspace: Xml.domToWorkspace, + appendDomToWorkspace: Xml.appendDomToWorkspace, + clearWorkspaceAndLoadFromXml: Xml.clearWorkspaceAndLoadFromXml, + saveWorkspaceComment: Xml.saveWorkspaceComment, + loadWorkspaceComment: Xml.loadWorkspaceComment, + }; + // Captured non-null: the wrappers below only exist while this is populated, + // which the module-level `let` cannot express. + const saved = originals; + + /** + * Runs a load through Blockly with comments held back, then adds the notes. + * Wrapped in one event group so a whole XML load is a single undo step; + * Blockly's own loader reuses an open group rather than starting its own. + * + * @param load The original Blockly loader to delegate to. + * @param xml The XML being loaded. + * @param workspace The target workspace. + * @returns The new block IDs, from Blockly. + */ + const loadWithNotes = ( + load: (xml: Element, workspace: W) => string[], + xml: Element, + workspace: W, + ): string[] => { + const {stripped, comments} = splitComments(xml); + const existingGroup = Blockly.Events.getGroup(); + if (!existingGroup) Blockly.Events.setGroup(true); + try { + const blockIds = load.call(Xml, stripped, workspace); + for (const elem of comments) domToNote(elem, workspace); + return blockIds; + } finally { + Blockly.Events.setGroup(existingGroup); + } + }; + + Xml.workspaceToDom = function (workspace, skipId = false) { + const dom = saved.workspaceToDom.call(Xml, workspace, skipId); + // Blockly emits one per top comment, in this same order, so the + // two line up by index. Only extra attributes are added, leaving Blockly's + // own output — including its RTL handling — exactly as it was. + const notes = workspace.getTopComments(); + topLevelComments(dom).forEach((elem, i) => { + const note = notes[i]; + if (isNote(note)) { + decorateElement(elem, saveNote(note, {addCoordinates: true})); + } + }); + return dom; + }; + + Xml.domToWorkspace = function (xml, workspace) { + return loadWithNotes(saved.domToWorkspace, xml, workspace); + }; + + // Blockly's own versions of these two reach domToWorkspace through a + // module-local binding, so the patch above never runs for them. Delegating + // the stripped XML to the originals keeps their extra behaviour — the + // block-offset maths in append, the clear in the other — intact. + Xml.appendDomToWorkspace = function (xml, workspace) { + return loadWithNotes(saved.appendDomToWorkspace, xml, workspace); + }; + + Xml.clearWorkspaceAndLoadFromXml = function (xml, workspace) { + return loadWithNotes(saved.clearWorkspaceAndLoadFromXml, xml, workspace); + }; + + // Direct callers of the per-comment helpers get note support too. + Xml.saveWorkspaceComment = function (comment, skipId = false) { + return isNote(comment) + ? noteToDom(comment, skipId) + : saved.saveWorkspaceComment.call(Xml, comment, skipId); + }; + + Xml.loadWorkspaceComment = function (elem, workspace) { + return domToNote(elem, workspace); + }; +} + +/** + * Puts one saved function back. + * + * Generic so the assignment is `PatchedXml[K] = PatchedXml[K]` for a single + * `K`, which TypeScript accepts; indexing with the whole union would not + * correlate the two sides. + * + * @param name Which entry point to restore. + * @param saved The saved originals. + */ +function restoreOne(name: K, saved: PatchedXml): void { + const target: PatchedXml = Xml; + target[name] = saved[name]; +} + +/** Restores Blockly's own XML functions. */ +export function unregisterXmlSupport(): void { + if (--registrationCount > 0) return; + registrationCount = 0; + if (!originals) return; + for (const name of PATCHED) restoreOne(name, originals); + originals = null; +} diff --git a/blockly-workspace-notes/test/colour.mocha.js b/blockly-workspace-notes/test/colour.mocha.js new file mode 100644 index 0000000..aa683f2 --- /dev/null +++ b/blockly-workspace-notes/test/colour.mocha.js @@ -0,0 +1,94 @@ +/** + * @fileoverview Tests for the colours derived from a note's own colour. + * + * A note stores one colour and the stylesheet reads two: the paper, and the + * edge that draws the card's outline and the rule under the title. The + * derivation is a pure function of a hex string, so it is checked here rather + * than through a workspace. + */ + +import {assert} from 'chai'; + +import {DEFAULT_PALETTE} from '../src/constants'; +import {colourForHue, edgeFor, hexToHsv} from '../src/colour'; + +/** + * @param {string} hex A hex colour. + * @returns {number} Its perceived lightness, as the HSV value scaled by how + * little colour is in it. Enough to order two shades of one hue. + */ +function lightness(hex) { + const [, saturation, value] = hexToHsv(hex); + return value * (1 - saturation / 2); +} + +/** + * Hues wrap, so two angles are compared the short way round. + * + * @param {number} a One hue in degrees. + * @param {number} b The other. + * @returns {number} The smaller angle between them. + */ +function hueGap(a, b) { + const gap = Math.abs(a - b) % 360; + return gap > 180 ? 360 - gap : gap; +} + +/** + * How far a derived hue may drift, in degrees. Not zero: a derivation lands on + * 8-bit channels, and the closer together they are the further that rounding + * moves the hue read back off them. + * @type {number} + */ +const HUE_TOLERANCE = 4; + +suite('Note colours', function () { + const colours = DEFAULT_PALETTE.map(({fill}) => fill); + + suite('edgeFor', function () { + test('the edge is darker than the paper', function () { + for (const colour of colours) { + assert.isBelow( + lightness(edgeFor(colour)), + lightness(colour), + `${colour} edged with ${edgeFor(colour)}`, + ); + } + }); + + test('it keeps the hue', function () { + for (const colour of colours) { + const [hue, saturation] = hexToHsv(colour); + if (saturation < 0.02) continue; + assert.isBelow( + hueGap(hue, hexToHsv(edgeFor(colour))[0]), + HUE_TOLERANCE, + colour, + ); + } + }); + }); + + test('the edge is a different shade from the paper', function () { + for (const colour of colours) { + assert.notEqual(edgeFor(colour), colour); + } + }); + + test('the derivation returns a hex colour, never NaN', function () { + // Sampled right around the hue circle, since hexToHsv branches on which + // channel is the maximum and the seven swatches do not cover every case. + for (let hue = 0; hue < 360; hue += 15) { + assert.match(edgeFor(colourForHue(hue)), /^#[0-9a-f]{6}$/, `${hue}deg`); + } + }); + + test('the palette is stored as the colours its own hues produce', function () { + for (const {name, hue, fill} of DEFAULT_PALETTE) { + // Grey is the exception: it is deliberately hueless, so no hue + // reproduces it. + if (name === 'Grey') continue; + assert.equal(colourForHue(hue), fill, name); + } + }); +}); diff --git a/blockly-workspace-notes/test/index.html b/blockly-workspace-notes/test/index.html new file mode 100644 index 0000000..dc96c57 --- /dev/null +++ b/blockly-workspace-notes/test/index.html @@ -0,0 +1,18 @@ + + + + + Blockly Plugin Test + + + + +
+ + + diff --git a/blockly-workspace-notes/test/index.js b/blockly-workspace-notes/test/index.js new file mode 100644 index 0000000..0a094ad --- /dev/null +++ b/blockly-workspace-notes/test/index.js @@ -0,0 +1,133 @@ +/** + * @fileoverview Playground for the workspace notes plugin. + * + * Rendered behaviour (dragging, resizing, the title in the top bar, the + * colour swatches) cannot be covered by the headless mocha tests, so this + * page is where it gets exercised. The "Save notes" / "Load notes" actions + * make the round-trip visible without opening devtools. + */ + +import * as Blockly from 'blockly'; +import {toolboxCategories, createPlayground} from '@blockly/dev-tools'; + +import {WorkspaceNotes} from '../src/index'; + +/** Holds the most recent save, so Load can restore it. */ +let savedState = null; + +/** The most recent XML save, for the XML round-trip actions. */ +let savedXml = null; + +/** + * Creates a workspace with the notes plugin attached. + * + * @param {HTMLElement} blocklyDiv The blockly container div. + * @param {!Blockly.BlocklyOptions} options The Blockly options. + * @returns {!Blockly.WorkspaceSvg} The created workspace. + */ +function createWorkspace(blocklyDiv, options) { + const workspace = Blockly.inject(blocklyDiv, options); + + const notes = new WorkspaceNotes(workspace, { + getAuthor: () => 'playground', + }); + notes.init(); + + // Handy for poking at things from the browser console. + globalThis.notesPlugin = notes; + + return workspace; +} + +document.addEventListener('DOMContentLoaded', function () { + const defaultOptions = { + toolbox: toolboxCategories, + }; + + createPlayground( + document.getElementById('root'), + createWorkspace, + defaultOptions, + ).then((playground) => { + playground.addAction('Add note', (workspace) => { + globalThis.notesPlugin.createNote({ + title: 'Note', + text: 'Say something...', + x: 40, + y: 40, + }); + console.log( + 'Notes on workspace:', + workspace.getTopComments(false).length, + ); + }); + + playground.addAction('Save notes', (workspace) => { + savedState = Blockly.serialization.workspaces.save(workspace); + console.log(JSON.stringify(savedState, null, 2)); + console.log( + 'workspaceComments key present:', + Object.hasOwn(savedState, 'workspaceComments'), + '| notes saved:', + savedState.workspaceNotes?.notes.length ?? 0, + ); + }); + + playground.addAction('Load notes', (workspace) => { + if (!savedState) { + console.warn('Nothing saved yet — press "Save notes" first.'); + return; + } + Blockly.serialization.workspaces.load(savedState, workspace); + console.log( + 'Notes after reload:', + workspace.getTopComments(false).length, + ); + }); + + playground.addAction('Save notes as XML', (workspace) => { + savedXml = Blockly.Xml.domToPrettyText( + Blockly.Xml.workspaceToDom(workspace), + ); + console.log(savedXml); + }); + + playground.addAction('Load notes from XML', (workspace) => { + if (!savedXml) { + console.warn('Nothing saved yet — press "Save notes as XML" first.'); + return; + } + Blockly.Xml.clearWorkspaceAndLoadFromXml( + Blockly.utils.xml.textToDom(savedXml), + workspace, + ); + console.log( + 'Notes after XML reload:', + workspace.getTopComments(false).length, + ); + }); + + playground.addAction('Load a legacy file', (workspace) => { + // No `workspaceNotes` key at all: what plain Blockly would have written. + Blockly.serialization.workspaces.load( + { + workspaceComments: [ + { + id: 'legacy1', + x: 60, + y: 220, + width: 220, + height: 110, + text: 'Saved before this plugin existed.', + }, + ], + }, + workspace, + ); + console.log( + 'Loaded legacy comment as a note:', + workspace.getCommentById('legacy1')?.constructor.name, + ); + }); + }); +}); diff --git a/blockly-workspace-notes/test/note.mocha.js b/blockly-workspace-notes/test/note.mocha.js new file mode 100644 index 0000000..02e0e0d --- /dev/null +++ b/blockly-workspace-notes/test/note.mocha.js @@ -0,0 +1,175 @@ +/** + * @fileoverview Model tests for notes. + * + * These run headlessly: `blockly/core` resolves to `core-node.js` in Node, + * which provides jsdom only for XML handling, so `Blockly.inject` — and + * therefore any rendered note — is unavailable. NoteComment carries the same + * state as its rendered sibling, which is exactly what these cover. + */ + +import * as Blockly from 'blockly/core'; +import {assert} from 'chai'; + +import {DEFAULT_COLOUR} from '../src/constants'; +import {NoteChange} from '../src/events'; +import {NoteComment, isNote, nextZIndex, previousZIndex} from '../src/note'; + +suite('Note model', function () { + setup(function () { + this.workspace = new Blockly.Workspace(); + }); + + teardown(function () { + this.workspace.dispose(); + }); + + suite('defaults', function () { + test('a new note has no title and the default colour', function () { + const note = new NoteComment(this.workspace); + assert.equal(note.getTitle(), ''); + assert.equal(note.getColour(), DEFAULT_COLOUR); + assert.isFalse(note.isPinned()); + assert.equal(note.getZIndex(), 0); + }); + + test('a new note stamps its metadata', function () { + const note = new NoteComment(this.workspace); + const meta = note.getMeta(); + assert.equal(meta.author, ''); + assert.isNotEmpty(meta.createdAt); + assert.equal(meta.updatedAt, meta.createdAt); + }); + + test('it registers as a top comment like any other', function () { + const note = new NoteComment(this.workspace); + assert.deepEqual(this.workspace.getTopComments(false), [note]); + assert.equal(this.workspace.getCommentById(note.id), note); + }); + }); + + suite('accessors', function () { + setup(function () { + this.note = new NoteComment(this.workspace); + }); + + test('setTitle round-trips', function () { + this.note.setTitle('Refactor'); + assert.equal(this.note.getTitle(), 'Refactor'); + }); + + test('setColour normalizes through Blockly colour parsing', function () { + this.note.setColour('#ffd6a5'); + assert.equal(this.note.getColour(), '#ffd6a5'); + }); + + test('an unparseable colour falls back to the default', function () { + this.note.setColour('not-a-colour'); + assert.equal(this.note.getColour(), DEFAULT_COLOUR); + }); + + test('setZIndex coerces to a number', function () { + this.note.setZIndex('4'); + assert.strictEqual(this.note.getZIndex(), 4); + }); + + test('editing bumps updatedAt but not createdAt', function () { + const before = this.note.getMeta(); + // Timestamps have millisecond resolution, so force a distinct one. + this.note.getNoteState().meta.updatedAt = '1970-01-01T00:00:00.000Z'; + this.note.setTitle('Changed'); + const after = this.note.getMeta(); + assert.equal(after.createdAt, before.createdAt); + assert.notEqual(after.updatedAt, '1970-01-01T00:00:00.000Z'); + }); + + test('restoreMeta does not bump updatedAt', function () { + this.note.restoreMeta({ + author: 'ada', + createdAt: '2020-01-01T00:00:00.000Z', + updatedAt: '2020-01-02T00:00:00.000Z', + }); + assert.deepEqual(this.note.getMeta(), { + author: 'ada', + createdAt: '2020-01-01T00:00:00.000Z', + updatedAt: '2020-01-02T00:00:00.000Z', + }); + }); + }); + + suite('pinning', function () { + test('pinning locks the note in place', function () { + const note = new NoteComment(this.workspace); + assert.isTrue(note.isOwnMovable()); + note.setPinned(true); + assert.isTrue(note.isPinned()); + assert.isFalse(note.isOwnMovable()); + }); + + test('unpinning releases it again', function () { + const note = new NoteComment(this.workspace); + note.setPinned(true); + note.setPinned(false); + assert.isFalse(note.isPinned()); + assert.isTrue(note.isOwnMovable()); + }); + }); + + suite('stacking helpers', function () { + test('nextZIndex is one past the highest in use', function () { + assert.equal(nextZIndex(this.workspace), 1); + const note = new NoteComment(this.workspace); + note.setZIndex(7); + assert.equal(nextZIndex(this.workspace), 8); + }); + + test('previousZIndex is one below the lowest in use', function () { + const note = new NoteComment(this.workspace); + note.setZIndex(-2); + assert.equal(previousZIndex(this.workspace), -3); + }); + }); + + suite('applyNoteProperty', function () { + setup(function () { + this.note = new NoteComment(this.workspace); + }); + + test("'*' applies a whole state object", function () { + this.note.applyNoteProperty('*', { + title: 'All', + colour: '#c7e4ff', + pinned: true, + zIndex: 3, + meta: {author: 'x', createdAt: 'a', updatedAt: 'b'}, + }); + assert.equal(this.note.getTitle(), 'All'); + assert.equal(this.note.getColour(), '#c7e4ff'); + assert.isTrue(this.note.isPinned()); + assert.equal(this.note.getZIndex(), 3); + assert.equal(this.note.getMeta().author, 'x'); + }); + + test("'*' with null is a no-op, so a delete snapshot replays safely", function () { + this.note.setTitle('Keep me'); + this.note.applyNoteProperty('*', null); + assert.equal(this.note.getTitle(), 'Keep me'); + }); + + test('it does not fire an event, unlike the setters', function () { + const fired = []; + this.workspace.addChangeListener((e) => fired.push(e)); + this.note.applyNoteProperty('title', 'Silent'); + assert.isEmpty(fired.filter((e) => e instanceof NoteChange)); + }); + }); + + suite('isNote', function () { + test('recognizes notes and rejects plain comments', function () { + assert.isTrue(isNote(new NoteComment(this.workspace))); + assert.isFalse( + isNote(new Blockly.comments.WorkspaceComment(this.workspace)), + ); + assert.isFalse(isNote(null)); + }); + }); +}); diff --git a/blockly-workspace-notes/test/serializer.mocha.js b/blockly-workspace-notes/test/serializer.mocha.js new file mode 100644 index 0000000..dccfe08 --- /dev/null +++ b/blockly-workspace-notes/test/serializer.mocha.js @@ -0,0 +1,409 @@ +/** + * @fileoverview Serialization tests: round-tripping, the sparse JSON shape, + * legacy files, migrations, and undo fidelity for note-specific fields. + */ + +import * as Blockly from 'blockly/core'; +import {assert} from 'chai'; + +import { + DEFAULT_COLOUR, + NOTE_SERIALIZER_NAME, + SCHEMA_VERSION, +} from '../src/constants'; +import {NoteComment, isNote} from '../src/note'; +import {WorkspaceNotes} from '../src/index'; +import {migrate, saveNote} from '../src/serializer'; + +/** + * Blockly queues events and flushes them on a later macrotask, so the undo + * stack is not populated synchronously. Await this before inspecting it. + * + * @returns {!Promise} Resolves once the queue has drained. + */ +function flushEvents() { + return new Promise((resolve) => setTimeout(resolve, 0)).then( + () => new Promise((resolve) => setTimeout(resolve, 0)), + ); +} + +suite('Note serialization', function () { + setup(function () { + this.workspace = new Blockly.Workspace(); + this.plugin = new WorkspaceNotes(this.workspace, {contextMenu: false}); + this.plugin.init(); + }); + + teardown(function () { + this.plugin.dispose(); + this.workspace.dispose(); + }); + + /** + * @param {!Blockly.Workspace} workspace The workspace to add the note to. + * @param {!object} [overrides] Fields to set on the new note. + * @returns {!NoteComment} A note on the test workspace. + */ + function makeNote(workspace, overrides = {}) { + const note = new NoteComment(workspace); + if (overrides.text) note.setText(overrides.text); + if (overrides.title) note.setTitle(overrides.title); + if (overrides.colour) note.setColour(overrides.colour); + if (overrides.zIndex) note.setZIndex(overrides.zIndex); + if (overrides.pinned) note.setPinned(true); + if (overrides.collapsed) note.setCollapsed(true); + note.moveTo( + new Blockly.utils.Coordinate(overrides.x ?? 0, overrides.y ?? 0), + ); + note.setSize( + new Blockly.utils.Size(overrides.width ?? 200, overrides.height ?? 120), + ); + return note; + } + + suite('save', function () { + test('an empty workspace writes no notes key at all', function () { + const state = Blockly.serialization.workspaces.save(this.workspace); + assert.notProperty(state, NOTE_SERIALIZER_NAME); + }); + + test('notes are written under a versioned envelope', function () { + makeNote(this.workspace, {text: 'hello'}); + const state = Blockly.serialization.workspaces.save(this.workspace); + assert.equal(state[NOTE_SERIALIZER_NAME].version, SCHEMA_VERSION); + assert.lengthOf(state[NOTE_SERIALIZER_NAME].notes, 1); + }); + + test('a default note omits every optional field', function () { + const note = makeNote(this.workspace, {x: 10, y: 20}); + const saved = saveNote(note, {addCoordinates: true, saveIds: true}); + + assert.deepEqual( + { + id: saved.id, + x: saved.x, + y: saved.y, + width: saved.width, + height: saved.height, + }, + {id: note.id, x: 10, y: 20, width: 200, height: 120}, + ); + for (const key of [ + 'text', + 'title', + 'colour', + 'collapsed', + 'pinned', + 'zIndex', + 'editable', + 'movable', + 'deletable', + ]) { + assert.notProperty(saved, key, `expected ${key} to be omitted`); + } + }); + + test('non-default flags are written, defaults are not', function () { + const note = makeNote(this.workspace, { + title: 'TODO', + colour: '#ffd6a5', + collapsed: true, + zIndex: 3, + }); + note.setEditable(false); + const saved = saveNote(note, {saveIds: true}); + + assert.equal(saved.title, 'TODO'); + assert.equal(saved.colour, '#ffd6a5'); + assert.isTrue(saved.collapsed); + assert.equal(saved.zIndex, 3); + assert.isFalse(saved.editable); + // `deletable` is still at its default, so it stays out of the file. + assert.notProperty(saved, 'deletable'); + }); + + test('a pinned note omits the movable flag it implies', function () { + const note = makeNote(this.workspace, {pinned: true}); + const saved = saveNote(note, {saveIds: true}); + assert.isTrue(saved.pinned); + assert.notProperty(saved, 'movable'); + }); + + test('the default colour is treated as "no colour"', function () { + const note = makeNote(this.workspace); + note.setColour(DEFAULT_COLOUR); + assert.notProperty(saveNote(note), 'colour'); + }); + }); + + suite('no duplication', function () { + test('notes are not also written under workspaceComments', function () { + makeNote(this.workspace, {text: 'only once'}); + const state = Blockly.serialization.workspaces.save(this.workspace); + assert.notProperty( + state, + 'workspaceComments', + 'the built-in comment serializer must not write notes a second time', + ); + }); + + test('a save/load cycle does not multiply notes', function () { + makeNote(this.workspace, {text: 'a'}); + makeNote(this.workspace, {text: 'b'}); + const state = Blockly.serialization.workspaces.save(this.workspace); + + const target = new Blockly.Workspace(); + try { + Blockly.serialization.workspaces.load(state, target); + assert.lengthOf(target.getTopComments(false), 2); + // And loading the same file again replaces rather than appends. + Blockly.serialization.workspaces.load(state, target); + assert.lengthOf(target.getTopComments(false), 2); + } finally { + target.dispose(); + } + }); + }); + + suite('round trip', function () { + test('every field survives save, load and save again', function () { + makeNote(this.workspace, { + text: 'Check this loop', + title: 'TODO', + colour: '#c7e4ff', + zIndex: 2, + x: 40, + y: 20, + width: 300, + height: 160, + }); + makeNote(this.workspace, { + text: 'Locked', + pinned: true, + collapsed: true, + x: 400, + y: 20, + }); + + const first = Blockly.serialization.workspaces.save(this.workspace); + + const target = new Blockly.Workspace(); + try { + Blockly.serialization.workspaces.load(first, target); + const second = Blockly.serialization.workspaces.save(target); + assert.deepEqual(second, first); + } finally { + target.dispose(); + } + }); + + test('loaded notes are notes, not plain comments', function () { + makeNote(this.workspace, {title: 'T'}); + const state = Blockly.serialization.workspaces.save(this.workspace); + + const target = new Blockly.Workspace(); + try { + Blockly.serialization.workspaces.load(state, target); + const [loaded] = target.getTopComments(false); + assert.isTrue(isNote(loaded)); + assert.equal(loaded.getTitle(), 'T'); + } finally { + target.dispose(); + } + }); + + test('metadata is restored verbatim rather than re-stamped', function () { + const note = makeNote(this.workspace); + note.restoreMeta({ + author: 'ada', + createdAt: '2020-01-01T00:00:00.000Z', + updatedAt: '2020-01-02T00:00:00.000Z', + }); + const state = Blockly.serialization.workspaces.save(this.workspace); + + const target = new Blockly.Workspace(); + try { + Blockly.serialization.workspaces.load(state, target); + assert.deepEqual(target.getTopComments(false)[0].getMeta(), { + author: 'ada', + createdAt: '2020-01-01T00:00:00.000Z', + updatedAt: '2020-01-02T00:00:00.000Z', + }); + } finally { + target.dispose(); + } + }); + }); + + suite('legacy files', function () { + test('a workspaceComments-only file loads as notes', function () { + Blockly.serialization.workspaces.load( + { + workspaceComments: [ + {id: 'legacy1', x: 5, y: 6, width: 100, height: 50, text: 'old'}, + ], + }, + this.workspace, + ); + + const loaded = this.workspace.getCommentById('legacy1'); + assert.isTrue(isNote(loaded)); + assert.equal(loaded.getText(), 'old'); + assert.equal(loaded.getColour(), DEFAULT_COLOUR); + }); + + test('a legacy file is re-saved in the new format', function () { + Blockly.serialization.workspaces.load( + {workspaceComments: [{id: 'legacy1', width: 100, height: 50}]}, + this.workspace, + ); + const state = Blockly.serialization.workspaces.save(this.workspace); + assert.notProperty(state, 'workspaceComments'); + assert.lengthOf(state[NOTE_SERIALIZER_NAME].notes, 1); + }); + + test('a note already loaded wins over a duplicate legacy entry', function () { + Blockly.serialization.workspaces.load( + { + workspaceNotes: { + version: SCHEMA_VERSION, + notes: [{id: 'dup', width: 100, height: 50, title: 'new'}], + }, + workspaceComments: [{id: 'dup', width: 100, height: 50}], + }, + this.workspace, + ); + + assert.lengthOf(this.workspace.getTopComments(false), 1); + assert.equal(this.workspace.getCommentById('dup').getTitle(), 'new'); + }); + }); + + suite('migrations', function () { + test('a bare array is treated as the unversioned shape', function () { + const migrated = migrate([{id: 'a', width: 1, height: 2}]); + assert.equal(migrated.version, SCHEMA_VERSION); + assert.lengthOf(migrated.notes, 1); + }); + + test('an explicit v0 envelope upgrades', function () { + const migrated = migrate({version: 0, notes: [{id: 'a'}]}); + assert.equal(migrated.version, SCHEMA_VERSION); + assert.lengthOf(migrated.notes, 1); + }); + + test('a current payload passes through unchanged', function () { + const state = {version: SCHEMA_VERSION, notes: [{id: 'a'}]}; + assert.deepEqual(migrate(state), state); + }); + + test('a newer payload keeps its notes rather than throwing', function () { + const migrated = migrate({version: 99, notes: [{id: 'a'}]}); + assert.equal(migrated.version, SCHEMA_VERSION); + assert.lengthOf(migrated.notes, 1); + }); + + test('an unversioned array loads end to end', function () { + Blockly.serialization.workspaces.load( + {workspaceNotes: [{id: 'bare', width: 100, height: 50, title: 'B'}]}, + this.workspace, + ); + assert.equal(this.workspace.getCommentById('bare').getTitle(), 'B'); + }); + }); + + suite('clear', function () { + test('loading disposes the notes already present', function () { + makeNote(this.workspace, {text: 'gone'}); + Blockly.serialization.workspaces.load( + {workspaceNotes: {version: SCHEMA_VERSION, notes: []}}, + this.workspace, + ); + assert.isEmpty(this.workspace.getTopComments(false)); + }); + + test('plain comments are cleared too, so they cannot accumulate', function () { + new Blockly.comments.WorkspaceComment(this.workspace); + Blockly.serialization.workspaces.load({}, this.workspace); + assert.isEmpty(this.workspace.getTopComments(false)); + }); + }); + + suite('undo', function () { + test('undoing a delete restores the title and colour', async function () { + const note = makeNote(this.workspace, { + title: 'Important', + colour: '#ffd6a5', + }); + const id = note.id; + await flushEvents(); + + note.dispose(); + await flushEvents(); + assert.isNull(this.workspace.getCommentById(id)); + + this.workspace.undo(false); + await flushEvents(); + + const restored = this.workspace.getCommentById(id); + assert.isNotNull(restored, 'the note should come back'); + assert.equal(restored.getTitle(), 'Important'); + assert.equal(restored.getColour(), '#ffd6a5'); + }); + + test('undoing a title change reverts it', async function () { + const note = makeNote(this.workspace, {title: 'Before'}); + await flushEvents(); + + note.setTitle('After'); + await flushEvents(); + assert.equal(note.getTitle(), 'After'); + + // One event, so one undo. The inline title editor wraps exactly this + // call in an event group, so renaming is a single step there too. + this.workspace.undo(false); + await flushEvents(); + assert.equal(note.getTitle(), 'Before'); + }); + + test('undoing a colour change reverts it', async function () { + const note = makeNote(this.workspace); + await flushEvents(); + + note.setColour('#c9efc2'); + await flushEvents(); + assert.equal(note.getColour(), '#c9efc2'); + + this.workspace.undo(false); + await flushEvents(); + assert.equal(note.getColour(), DEFAULT_COLOUR); + + this.workspace.redo(); + await flushEvents(); + assert.equal(note.getColour(), '#c9efc2'); + }); + + test('a no-op change never reaches the undo stack', async function () { + const note = makeNote(this.workspace, {title: 'Same'}); + await flushEvents(); + const depth = this.workspace.getUndoStack().length; + + note.setTitle('Same'); + await flushEvents(); + assert.equal(this.workspace.getUndoStack().length, depth); + }); + + test('loading a file does not fill the undo stack', async function () { + const state = { + workspaceNotes: { + version: SCHEMA_VERSION, + notes: [{id: 'a', width: 100, height: 50, title: 'A'}], + }, + }; + this.workspace.clearUndo(); + Blockly.serialization.workspaces.load(state, this.workspace); + await flushEvents(); + assert.isEmpty(this.workspace.getUndoStack()); + }); + }); +}); diff --git a/blockly-workspace-notes/test/xml.mocha.js b/blockly-workspace-notes/test/xml.mocha.js new file mode 100644 index 0000000..2d4739c --- /dev/null +++ b/blockly-workspace-notes/test/xml.mocha.js @@ -0,0 +1,417 @@ +/** + * @fileoverview XML serialization tests. + * + * Unlike the rendered chrome, `Blockly.Xml` works headlessly — `core-node.js` + * injects jsdom precisely so XML handling works in Node — so the whole format + * is covered here. + */ + +import * as Blockly from 'blockly/core'; +import {assert} from 'chai'; + +import {DEFAULT_COLOUR} from '../src/constants'; +import {NoteComment, isNote} from '../src/note'; +import {WorkspaceNotes} from '../src/index'; +import {domToNoteState, noteToDom} from '../src/xml'; + +/** + * @param {!Element} dom An `` element. + * @returns {!Array} Its top-level `` children. + */ +function comments(dom) { + return Array.from(dom.childNodes).filter( + (node) => node.nodeName.toLowerCase() === 'comment', + ); +} + +suite('Note XML serialization', function () { + setup(function () { + this.workspace = new Blockly.Workspace(); + this.plugin = new WorkspaceNotes(this.workspace, {contextMenu: false}); + this.plugin.init(); + }); + + teardown(function () { + this.plugin.dispose(); + this.workspace.dispose(); + }); + + /** + * @param {!Blockly.Workspace} workspace The workspace to add to. + * @param {!object} [overrides] Fields to set on the note. + * @returns {!NoteComment} The new note. + */ + function makeNote(workspace, overrides = {}) { + const note = new NoteComment(workspace); + if (overrides.text) note.setText(overrides.text); + if (overrides.title) note.setTitle(overrides.title); + if (overrides.colour) note.setColour(overrides.colour); + if (overrides.zIndex) note.setZIndex(overrides.zIndex); + if (overrides.pinned) note.setPinned(true); + if (overrides.collapsed) note.setCollapsed(true); + note.moveTo( + new Blockly.utils.Coordinate(overrides.x ?? 0, overrides.y ?? 0), + ); + note.setSize( + new Blockly.utils.Size(overrides.width ?? 200, overrides.height ?? 120), + ); + return note; + } + + suite('writing', function () { + test('note fields are written as attributes on ', function () { + makeNote(this.workspace, { + text: 'body', + title: 'TODO', + colour: '#ffd6a5', + zIndex: 3, + x: 40, + y: 20, + }); + + const [elem] = comments(Blockly.Xml.workspaceToDom(this.workspace)); + assert.equal(elem.getAttribute('title'), 'TODO'); + assert.equal(elem.getAttribute('colour'), '#ffd6a5'); + assert.equal(elem.getAttribute('z'), '3'); + assert.equal(elem.textContent, 'body'); + }); + + test("core's own attributes are left untouched", function () { + makeNote(this.workspace, {x: 40, y: 20, width: 250, height: 130}); + const [elem] = comments(Blockly.Xml.workspaceToDom(this.workspace)); + assert.equal(elem.getAttribute('x'), '40'); + assert.equal(elem.getAttribute('y'), '20'); + assert.equal(elem.getAttribute('w'), '250'); + assert.equal(elem.getAttribute('h'), '130'); + assert.isNotNull(elem.getAttribute('id')); + }); + + test('a plain note writes no note-specific attributes', function () { + makeNote(this.workspace); + const [elem] = comments(Blockly.Xml.workspaceToDom(this.workspace)); + for (const name of ['title', 'colour', 'pinned', 'z']) { + assert.isNull( + elem.getAttribute(name), + `expected ${name} to be omitted`, + ); + } + }); + + test('skipId omits the id, as it does for a plain comment', function () { + makeNote(this.workspace, {title: 'T'}); + const [elem] = comments(Blockly.Xml.workspaceToDom(this.workspace, true)); + assert.isNull(elem.getAttribute('id')); + assert.equal(elem.getAttribute('title'), 'T'); + }); + + test('attributes line up when several notes are saved at once', function () { + makeNote(this.workspace, {title: 'first', colour: '#ffd6a5'}); + makeNote(this.workspace, {title: 'second', colour: '#c7e4ff'}); + makeNote(this.workspace, {title: 'third'}); + + const elems = comments(Blockly.Xml.workspaceToDom(this.workspace)); + assert.deepEqual( + elems.map((e) => e.getAttribute('title')), + ['first', 'second', 'third'], + ); + assert.deepEqual( + elems.map((e) => e.getAttribute('colour')), + ['#ffd6a5', '#c7e4ff', null], + ); + }); + + test('metadata is written', function () { + const note = makeNote(this.workspace); + note.restoreMeta({ + author: 'ada', + createdAt: '2020-01-01T00:00:00.000Z', + updatedAt: '2020-01-02T00:00:00.000Z', + }); + const [elem] = comments(Blockly.Xml.workspaceToDom(this.workspace)); + assert.equal(elem.getAttribute('author'), 'ada'); + assert.equal(elem.getAttribute('created'), '2020-01-01T00:00:00.000Z'); + assert.equal(elem.getAttribute('updated'), '2020-01-02T00:00:00.000Z'); + }); + }); + + suite('reading', function () { + test('a decorated loads as a note', function () { + const dom = Blockly.utils.xml.textToDom( + 'body', + ); + Blockly.Xml.domToWorkspace(dom, this.workspace); + + const note = this.workspace.getCommentById('n1'); + assert.isTrue(isNote(note), 'should be a note, not a plain comment'); + assert.equal(note.getTitle(), 'TODO'); + assert.equal(note.getColour(), '#ffd6a5'); + assert.equal(note.getZIndex(), 2); + assert.equal(note.getText(), 'body'); + assert.equal(note.getSize().width, 250); + }); + + test('a pinned note comes back locked', function () { + const dom = Blockly.utils.xml.textToDom( + '', + ); + Blockly.Xml.domToWorkspace(dom, this.workspace); + const note = this.workspace.getCommentById('n1'); + assert.isTrue(note.isPinned()); + assert.isFalse(note.isOwnMovable()); + }); + + test('a plain old still loads, with defaults', function () { + const dom = Blockly.utils.xml.textToDom( + 'legacy' + + '', + ); + Blockly.Xml.domToWorkspace(dom, this.workspace); + + const note = this.workspace.getCommentById('old'); + assert.isTrue(isNote(note)); + assert.equal(note.getText(), 'legacy'); + assert.equal(note.getColour(), DEFAULT_COLOUR); + assert.equal(note.getTitle(), ''); + }); + + test('blocks alongside comments still load, and are returned', function () { + Blockly.defineBlocksWithJsonArray([ + {type: 'xml_test_block', message0: 'test'}, + ]); + try { + const dom = Blockly.utils.xml.textToDom( + '' + + '', + ); + const ids = Blockly.Xml.domToWorkspace(dom, this.workspace); + assert.deepEqual(ids, ['b1']); + assert.equal(this.workspace.getCommentById('n1').getTitle(), 'T'); + assert.isNotNull(this.workspace.getBlockById('b1')); + } finally { + delete Blockly.Blocks['xml_test_block']; + } + }); + + test('the caller’s DOM is not mutated', function () { + const dom = Blockly.utils.xml.textToDom( + '', + ); + Blockly.Xml.domToWorkspace(dom, this.workspace); + assert.lengthOf( + comments(dom), + 1, + 'the comment element must survive so the DOM can be loaded again', + ); + + // And loading the same DOM a second time still works. + const second = new Blockly.Workspace(); + try { + Blockly.Xml.domToWorkspace(dom, second); + assert.equal(second.getCommentById('n1').getTitle(), 'T'); + } finally { + second.dispose(); + } + }); + + test('appendDomToWorkspace also produces notes', function () { + const dom = Blockly.utils.xml.textToDom( + '', + ); + // Blockly reaches domToWorkspace through a module-local binding here, + // so this only works because the entry point is wrapped separately. + Blockly.Xml.appendDomToWorkspace(dom, this.workspace); + assert.equal(this.workspace.getCommentById('n1').getTitle(), 'Appended'); + }); + }); + + suite('round trip', function () { + test('every field survives a save and load', function () { + makeNote(this.workspace, { + text: 'Check this loop', + title: 'TODO', + colour: '#c7e4ff', + zIndex: 2, + x: 40, + y: 20, + width: 300, + height: 160, + }); + makeNote(this.workspace, { + text: 'Locked', + pinned: true, + collapsed: true, + x: 400, + y: 20, + }); + + const first = Blockly.Xml.domToText( + Blockly.Xml.workspaceToDom(this.workspace), + ); + + const target = new Blockly.Workspace(); + const plugin = new WorkspaceNotes(target, {contextMenu: false}); + plugin.init(); + try { + Blockly.Xml.domToWorkspace(Blockly.utils.xml.textToDom(first), target); + const second = Blockly.Xml.domToText( + Blockly.Xml.workspaceToDom(target), + ); + assert.equal(second, first); + } finally { + plugin.dispose(); + target.dispose(); + } + }); + + test('XML and JSON agree on the same workspace', function () { + makeNote(this.workspace, { + text: 'body', + title: 'TODO', + colour: '#ffd6a5', + zIndex: 4, + x: 12, + y: 34, + width: 210, + height: 140, + }); + + const viaJson = new Blockly.Workspace(); + const viaXml = new Blockly.Workspace(); + const jsonPlugin = new WorkspaceNotes(viaJson, {contextMenu: false}); + const xmlPlugin = new WorkspaceNotes(viaXml, {contextMenu: false}); + jsonPlugin.init(); + xmlPlugin.init(); + try { + Blockly.serialization.workspaces.load( + Blockly.serialization.workspaces.save(this.workspace), + viaJson, + ); + Blockly.Xml.domToWorkspace( + Blockly.Xml.workspaceToDom(this.workspace), + viaXml, + ); + + const describe = (ws) => + ws.getTopComments(false).map((n) => ({ + title: n.getTitle(), + colour: n.getColour(), + zIndex: n.getZIndex(), + text: n.getText(), + pinned: n.isPinned(), + xy: n.getRelativeToSurfaceXY(), + size: n.getSize(), + })); + + assert.deepEqual(describe(viaXml), describe(viaJson)); + } finally { + jsonPlugin.dispose(); + xmlPlugin.dispose(); + viaJson.dispose(); + viaXml.dispose(); + } + }); + + test('a note stays readable by plain Blockly', function () { + makeNote(this.workspace, { + text: 'body', + title: 'TODO', + colour: '#ffd6a5', + x: 40, + y: 20, + width: 250, + height: 130, + }); + const xml = Blockly.Xml.workspaceToDom(this.workspace); + + // Drop the plugin, so Blockly's own loader handles the same document. + this.plugin.dispose(); + const target = new Blockly.Workspace(); + try { + Blockly.Xml.domToWorkspace(xml, target); + const [comment] = target.getTopComments(false); + assert.isFalse(isNote(comment), 'plain Blockly makes a plain comment'); + assert.equal(comment.getText(), 'body'); + assert.equal(comment.getSize().width, 250); + } finally { + target.dispose(); + this.plugin.init(); + } + }); + }); + + suite('helpers', function () { + test('domToNoteState reads every attribute', function () { + const dom = Blockly.utils.xml.textToDom( + 'body', + ); + const [elem] = comments(dom); + assert.deepEqual(domToNoteState(elem), { + id: 'n1', + x: 1, + y: 2, + width: 3, + height: 4, + text: 'body', + collapsed: true, + editable: false, + movable: false, + deletable: false, + title: 'T', + colour: '#ffd6a5', + pinned: true, + zIndex: 7, + meta: {author: 'ada', createdAt: 'c', updatedAt: 'u'}, + }); + }); + + test('malformed geometry is skipped rather than stored as NaN', function () { + const dom = Blockly.utils.xml.textToDom( + '', + ); + const state = domToNoteState(comments(dom)[0]); + assert.notProperty(state, 'x'); + assert.notProperty(state, 'y'); + assert.notProperty(state, 'width'); + assert.notProperty(state, 'height'); + }); + + test('noteToDom and domToNoteState are inverses', function () { + const note = makeNote(this.workspace, { + text: 'body', + title: 'T', + colour: '#ffd6a5', + zIndex: 5, + x: 11, + y: 22, + }); + const state = domToNoteState(noteToDom(note)); + assert.equal(state.title, 'T'); + assert.equal(state.colour, '#ffd6a5'); + assert.equal(state.zIndex, 5); + assert.equal(state.x, 11); + assert.equal(state.y, 22); + assert.equal(state.text, 'body'); + }); + }); + + suite('teardown', function () { + test('dispose restores Blockly’s own XML functions', function () { + const patched = Blockly.Xml.workspaceToDom; + this.plugin.dispose(); + try { + assert.notEqual(Blockly.Xml.workspaceToDom, patched); + makeNote(this.workspace, {title: 'ignored'}); + const [elem] = comments(Blockly.Xml.workspaceToDom(this.workspace)); + assert.isNull(elem.getAttribute('title')); + } finally { + this.plugin.init(); + } + }); + }); +}); diff --git a/blockly-workspace-notes/tsconfig.json b/blockly-workspace-notes/tsconfig.json new file mode 100644 index 0000000..ad4cfd0 --- /dev/null +++ b/blockly-workspace-notes/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "rootDir": "./src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "module": "es2015", + "moduleResolution": "bundler", + "target": "es6", + "strict": true, + + // Explicit rather than inherited. At `target: es6` the implied lib is + // ES2015, which has no `Object.values`; this compiled only because + // `@types/node` happens to reference `lib es2020`. `types: []` stops that + // accident — src/ is browser-only, and the Node-flavoured code lives in + // test/*.js, which is not part of this program. + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "types": [], + + "skipLibCheck": true, + + // Already the default at `target: es6`, set explicitly because it is + // load-bearing: a field definition emitted after `super()` would wipe + // state the base constructor had already caused to be built. + "useDefineForClassFields": false, + + "isolatedModules": true + }, + // test/**/* is JavaScript on purpose — @blockly/dev-scripts finds test + // entries with a literal `.mocha.js` filter — so it is never in the program. + "include": ["src"] +} From e21dae4f47d428982a9e585fd310ced7236d08aa Mon Sep 17 00:00:00 2001 From: Preet Vadaliya Date: Tue, 8 Sep 2026 19:59:21 +0200 Subject: [PATCH 02/20] refactor: split src into layered modules --- blockly-workspace-notes/DESIGN.md | 45 +- .../{paster.ts => clipboard/note_paster.ts} | 7 +- .../src/constants/colours.ts | 54 ++ blockly-workspace-notes/src/constants/dom.ts | 30 + .../src/{constants.ts => constants/layout.ts} | 106 +-- .../src/constants/serialization.ts | 34 + .../src/{events.ts => events/note_change.ts} | 22 +- .../src/events/registry.ts | 28 + blockly-workspace-notes/src/index.ts | 231 +----- blockly-workspace-notes/src/model/note.ts | 337 ++++++++ .../src/model/note_comment.ts | 14 + .../src/model/note_mixin.ts | 322 ++++++++ blockly-workspace-notes/src/model/stacking.ts | 62 ++ blockly-workspace-notes/src/note.ts | 724 ------------------ blockly-workspace-notes/src/plugin.ts | 192 +++++ .../serialization/legacy_comment_adapter.ts | 80 ++ .../src/serialization/migrations.ts | 58 ++ .../src/serialization/note_serializer.ts | 79 ++ .../src/serialization/registry.ts | 77 ++ .../src/serialization/state.ts | 164 ++++ .../src/serialization/xml/dom.ts | 175 +++++ .../src/serialization/xml/patch.ts | 174 +++++ blockly-workspace-notes/src/serializer.ts | 407 ---------- blockly-workspace-notes/src/types.ts | 133 ---- .../src/types/clipboard.ts | 17 + blockly-workspace-notes/src/types/events.ts | 19 + blockly-workspace-notes/src/types/note.ts | 79 ++ blockly-workspace-notes/src/types/options.ts | 29 + .../src/types/serialization.ts | 40 + .../src/ui/colour_swatches.ts | 88 +++ .../src/{ => ui}/context_menu.ts | 102 +-- blockly-workspace-notes/src/{ => ui}/css.ts | 8 +- .../src/{ => ui}/title_editor.ts | 18 +- .../src/{ => utils}/colour.ts | 6 +- blockly-workspace-notes/src/utils/guards.ts | 22 + blockly-workspace-notes/src/utils/messages.ts | 20 + blockly-workspace-notes/src/utils/undo.ts | 26 + blockly-workspace-notes/src/xml.ts | 325 -------- blockly-workspace-notes/test/colour.mocha.js | 4 +- blockly-workspace-notes/test/note.mocha.js | 5 +- .../test/serializer.mocha.js | 10 +- blockly-workspace-notes/test/xml.mocha.js | 12 +- 42 files changed, 2351 insertions(+), 2034 deletions(-) rename blockly-workspace-notes/src/{paster.ts => clipboard/note_paster.ts} (95%) create mode 100644 blockly-workspace-notes/src/constants/colours.ts create mode 100644 blockly-workspace-notes/src/constants/dom.ts rename blockly-workspace-notes/src/{constants.ts => constants/layout.ts} (56%) create mode 100644 blockly-workspace-notes/src/constants/serialization.ts rename blockly-workspace-notes/src/{events.ts => events/note_change.ts} (86%) create mode 100644 blockly-workspace-notes/src/events/registry.ts create mode 100644 blockly-workspace-notes/src/model/note.ts create mode 100644 blockly-workspace-notes/src/model/note_comment.ts create mode 100644 blockly-workspace-notes/src/model/note_mixin.ts create mode 100644 blockly-workspace-notes/src/model/stacking.ts delete mode 100644 blockly-workspace-notes/src/note.ts create mode 100644 blockly-workspace-notes/src/plugin.ts create mode 100644 blockly-workspace-notes/src/serialization/legacy_comment_adapter.ts create mode 100644 blockly-workspace-notes/src/serialization/migrations.ts create mode 100644 blockly-workspace-notes/src/serialization/note_serializer.ts create mode 100644 blockly-workspace-notes/src/serialization/registry.ts create mode 100644 blockly-workspace-notes/src/serialization/state.ts create mode 100644 blockly-workspace-notes/src/serialization/xml/dom.ts create mode 100644 blockly-workspace-notes/src/serialization/xml/patch.ts delete mode 100644 blockly-workspace-notes/src/serializer.ts delete mode 100644 blockly-workspace-notes/src/types.ts create mode 100644 blockly-workspace-notes/src/types/clipboard.ts create mode 100644 blockly-workspace-notes/src/types/events.ts create mode 100644 blockly-workspace-notes/src/types/note.ts create mode 100644 blockly-workspace-notes/src/types/options.ts create mode 100644 blockly-workspace-notes/src/types/serialization.ts create mode 100644 blockly-workspace-notes/src/ui/colour_swatches.ts rename blockly-workspace-notes/src/{ => ui}/context_menu.ts (74%) rename blockly-workspace-notes/src/{ => ui}/css.ts (99%) rename blockly-workspace-notes/src/{ => ui}/title_editor.ts (93%) rename blockly-workspace-notes/src/{ => utils}/colour.ts (96%) create mode 100644 blockly-workspace-notes/src/utils/guards.ts create mode 100644 blockly-workspace-notes/src/utils/messages.ts create mode 100644 blockly-workspace-notes/src/utils/undo.ts delete mode 100644 blockly-workspace-notes/src/xml.ts diff --git a/blockly-workspace-notes/DESIGN.md b/blockly-workspace-notes/DESIGN.md index 05e42a2..6029ed5 100644 --- a/blockly-workspace-notes/DESIGN.md +++ b/blockly-workspace-notes/DESIGN.md @@ -368,7 +368,48 @@ together: S 0.25 at V 0.98, against a block's S 0.45 at V 0.65. --- -## 10. 🚧 Limits and future work +## 10. đŸ—‚ī¸ How the code is laid out + +Each folder is one layer, and a file is named for the single thing it holds. +Reading top to bottom is roughly reading the plugin's dependency order. + +``` +src/ +├── index.ts The whole public surface. Re-exports only, no logic. +├── plugin.ts WorkspaceNotes: what a host constructs. +├── model/ What a note is. note_mixin applies to both classes. +├── serialization/ JSON in and out, plus xml/ for the older format. +├── events/ The undo event a note fires. +├── clipboard/ Pasting a note with its title and colour intact. +├── ui/ Chrome the user touches: menu, title editor, CSS. +├── utils/ Pure helpers. No registration, no module state. +├── constants/ Numbers and names, grouped by what they configure. +└── types/ The shapes that travel between the layers. +``` + +Four rules keep it that way: + +- **`index.ts` holds no logic.** Anything it does not re-export is internal and + free to move. It is also the build entry point, resolved by path, so it stays + where it is. +- **No barrel files inside the folders.** Every import names the file it wants + (`../constants/layout`, never `../constants`). Longer to type, and the reason + the import graph stays legible as the code grows. +- **`utils/` is inert.** Pure functions, no Blockly registration, no + module-level state — importable from a test with nothing set up. +- **Registration lives in `registry.ts`.** Anything that mutates a global + Blockly registry does it in a file named `registry.ts` (or `xml/patch.ts`), + so the side effects are findable in one sweep and every one of them has a + matching `unregister`. + +One import cycle exists on purpose: `model/note.ts` and `model/stacking.ts` +need each other, since a note restacks its neighbours when its z-index changes +and restacking needs the class to recognise a note. Both uses sit inside +function bodies, so neither runs while the modules are still evaluating. + +--- + +## 11. 🚧 Limits and future work ### Known limits @@ -390,7 +431,7 @@ together: S 0.25 at V 0.98, against a block's S 0.45 at V 0.65. --- -## 11. ❓ Open questions +## 12. ❓ Open questions 1. Should pinned notes stay fixed on screen instead of on the canvas? (See section 9.) diff --git a/blockly-workspace-notes/src/paster.ts b/blockly-workspace-notes/src/clipboard/note_paster.ts similarity index 95% rename from blockly-workspace-notes/src/paster.ts rename to blockly-workspace-notes/src/clipboard/note_paster.ts index 66befb3..c33a4ac 100644 --- a/blockly-workspace-notes/src/paster.ts +++ b/blockly-workspace-notes/src/clipboard/note_paster.ts @@ -10,9 +10,10 @@ import * as Blockly from 'blockly/core'; -import type {NoteCopyData, SavedNote} from './types'; -import {Note} from './note'; -import {appendNote} from './serializer'; +import {Note} from '../model/note'; +import {appendNote} from '../serialization/state'; +import type {NoteCopyData} from '../types/clipboard'; +import type {SavedNote} from '../types/serialization'; /** * The paster type core registers for workspace comments. Matching it means diff --git a/blockly-workspace-notes/src/constants/colours.ts b/blockly-workspace-notes/src/constants/colours.ts new file mode 100644 index 0000000..0380e8f --- /dev/null +++ b/blockly-workspace-notes/src/constants/colours.ts @@ -0,0 +1,54 @@ +/** + * @fileoverview The colour space a note lives in, and the palette it is + * offered from. + * + * The derivations that read these live in `utils/colour.ts`; this file only + * states the numbers. + */ + +import type {PaletteEntry} from '../types/options'; + +/** + * A note is paper: a light colour carrying dark text. + * + * That is the real difference from a block. Blockly's block language is a + * saturated fill with a white label — S 0.45 at V 0.65 via `hueToHex` — and + * anything built that way reads as a block whatever hue it uses. Notes invert + * it: a pale wash at V 0.98, with the text in black. + * + * It is also what Blockly's own comments do. Their default is `#FFFCC7`, a + * pale yellow, and nothing about a comment borrows the block palette. + */ +export const NOTE_SATURATION = 0.25; + +/** The value every note colour is generated at, on a 0-1 scale. */ +export const NOTE_VALUE = 0.98; + +/** Hue of the default note: the yellow a sticky note is expected to be. */ +export const DEFAULT_HUE = 48; + +/** The default note colour. */ +export const DEFAULT_COLOUR = '#f9edbb'; + +/** + * The swatches offered in the "Colour" context menu, in display order. + * + * Stationery colours rather than block colours. Each is stored as the hex the + * palette's saturation and value produce, since that is what a note + * serializes. + */ +export const DEFAULT_PALETTE: PaletteEntry[] = [ + {name: 'Yellow', hue: DEFAULT_HUE, fill: DEFAULT_COLOUR}, + {name: 'Peach', hue: 28, fill: '#f9d8bb'}, + {name: 'Pink', hue: 350, fill: '#f9bbc5'}, + {name: 'Lilac', hue: 275, fill: '#dfbbf9'}, + {name: 'Sky', hue: 200, fill: '#bbe5f9'}, + {name: 'Mint', hue: 150, fill: '#bbf9da'}, + {name: 'Grey', hue: 0, fill: '#f2f2f2'}, +]; + +/** + * How far a note's edge sits below its own colour: the same hue, a step down + * in value. Used for the card's hairline and the writing area's border. + */ +export const EDGE_VALUE_SCALE = 0.88; diff --git a/blockly-workspace-notes/src/constants/dom.ts b/blockly-workspace-notes/src/constants/dom.ts new file mode 100644 index 0000000..4ca6d47 --- /dev/null +++ b/blockly-workspace-notes/src/constants/dom.ts @@ -0,0 +1,30 @@ +/** + * @fileoverview The class names a note's chrome is marked with, and the one + * piece of text the chrome renders on its own. + * + * The classes are the contract between `model/note.ts`, which adds them, and + * `ui/css.ts`, which styles them. Neither should spell one out inline. + */ + +/** CSS class added to the root SVG group of every note. */ +export const NOTE_CLASS = 'blocklyNote'; + +/** CSS class added to a note that has a non-empty title. */ +export const TITLED_CLASS = 'blocklyNoteTitled'; + +/** CSS class added to a pinned note. */ +export const PINNED_CLASS = 'blocklyNotePinned'; + +/** CSS class of the SVG text element that renders a note's title. */ +export const TITLE_CLASS = 'blocklyNoteTitle'; + +/** CSS class of the hairline drawn under a note's title. */ +export const RULE_CLASS = 'blocklyNoteRule'; + +/** + * Shown in the title row of a note that has not been named yet. + * + * A note always has a title row, so an unnamed one is labelled rather than + * left blank: the placeholder is what says the row can be clicked. + */ +export const UNTITLED_TITLE_TEXT = 'Title'; diff --git a/blockly-workspace-notes/src/constants.ts b/blockly-workspace-notes/src/constants/layout.ts similarity index 56% rename from blockly-workspace-notes/src/constants.ts rename to blockly-workspace-notes/src/constants/layout.ts index f947754..1fbca95 100644 --- a/blockly-workspace-notes/src/constants.ts +++ b/blockly-workspace-notes/src/constants/layout.ts @@ -1,93 +1,11 @@ /** - * @fileoverview Shared constants for the workspace notes plugin. - */ - -/** - * The name the note serializer registers under. This doubles as the top-level - * key in the JSON produced by `Blockly.serialization.workspaces.save()`. - */ -export const NOTE_SERIALIZER_NAME = 'workspaceNotes'; - -/** - * The name of Blockly's built-in workspace comment serializer, which we - * replace so that notes are not saved twice. - */ -export const COMMENT_SERIALIZER_NAME = 'workspaceComments'; - -/** - * Current version of the `workspaceNotes` payload. Bump this whenever the - * shape changes, and add a matching entry to MIGRATIONS in serializer.js. - */ -export const SCHEMA_VERSION = 1; - -/** - * The type string of the custom event used to make note-specific properties - * undoable. Namespaced to avoid colliding with other plugins in the global - * event registry. - */ -export const NOTE_CHANGE_EVENT_TYPE = 'workspace_note_change'; - -/** - * A note is paper: a light colour carrying dark text. - * - * That is the real difference from a block. Blockly's block language is a - * saturated fill with a white label — S 0.45 at V 0.65 via `hueToHex` — and - * anything built that way reads as a block whatever hue it uses. Notes invert - * it: a pale wash at V 0.98, with the text in black. - * - * It is also what Blockly's own comments do. Their default is `#FFFCC7`, a - * pale yellow, and nothing about a comment borrows the block palette. - */ -export const NOTE_SATURATION = 0.25; - -/** @type {number} */ -export const NOTE_VALUE = 0.98; - -/** Hue of the default note: the yellow a sticky note is expected to be. */ -export const DEFAULT_HUE = 48; - -/** The default note colour. */ -export const DEFAULT_COLOUR = '#f9edbb'; - -/** - * The swatches offered in the "Colour" context menu, in display order. + * @fileoverview The geometry a note's chrome is built from. * - * Stationery colours rather than block colours. Each is stored as the hex the - * palette's saturation and value produce, since that is what a note - * serializes. + * Every number here is derived from one of two sources: Blockly's own renderer + * scale, so a note sits comfortably beside the blocks it annotates, or + * `TITLE_LINE_HEIGHT`, which is the single unit the note's own layout is + * measured in. Nothing in the chrome should introduce an ad-hoc number. */ -export const DEFAULT_PALETTE = [ - {name: 'Yellow', hue: DEFAULT_HUE, fill: DEFAULT_COLOUR}, - {name: 'Peach', hue: 28, fill: '#f9d8bb'}, - {name: 'Pink', hue: 350, fill: '#f9bbc5'}, - {name: 'Lilac', hue: 275, fill: '#dfbbf9'}, - {name: 'Sky', hue: 200, fill: '#bbe5f9'}, - {name: 'Mint', hue: 150, fill: '#bbf9da'}, - {name: 'Grey', hue: 0, fill: '#f2f2f2'}, -]; - -/** CSS class added to the root SVG group of every note. */ -export const NOTE_CLASS = 'blocklyNote'; - -/** CSS class added to a note that has a non-empty title. */ -export const TITLED_CLASS = 'blocklyNoteTitled'; - -/** CSS class added to a pinned note. */ -export const PINNED_CLASS = 'blocklyNotePinned'; - -/** CSS class of the SVG text element that renders a note's title. */ -export const TITLE_CLASS = 'blocklyNoteTitle'; - -/** CSS class of the hairline drawn under a note's title. */ -export const RULE_CLASS = 'blocklyNoteRule'; - -/** - * Shown in the title row of a note that has not been named yet. - * - * A note always has a title row, so an unnamed one is labelled rather than - * left blank: the placeholder is what says the row can be clicked. - */ -export const UNTITLED_TITLE_TEXT = 'Title'; /** * Blockly's renderer padding scale, from `renderers/common/constants.ts`. @@ -95,10 +13,10 @@ export const UNTITLED_TITLE_TEXT = 'Title'; */ export const SMALL_PADDING = 3; -/** @type {number} */ +/** The middle step of Blockly's padding scale. */ export const MEDIUM_PADDING = 5; -/** @type {number} */ +/** The largest step of Blockly's padding scale. */ export const LARGE_PADDING = 10; /** @@ -152,6 +70,9 @@ export const SCROLLBAR_WIDTH = 8; /** Space between a note's edge and its writing area. */ export const BODY_INSET = NOTE_MARGIN; +/** The bottom of the title's line box, measured from the note's top. */ +const TITLE_BOTTOM = (TOPBAR_HEIGHT + TITLE_LINE_HEIGHT) / 2; + /** * Where the hairline under the title sits, measured from the note's top. * @@ -164,7 +85,6 @@ export const BODY_INSET = NOTE_MARGIN; * coloured body is exactly how Blockly draws a field on a block, so anything * built that way reads as a block however the note itself is shaped. */ -const TITLE_BOTTOM = (TOPBAR_HEIGHT + TITLE_LINE_HEIGHT) / 2; export const TITLE_RULE_Y = (TITLE_BOTTOM + TOPBAR_HEIGHT) / 2; /** @@ -198,9 +118,3 @@ export const MIN_SIZE = { width: NOTE_MARGIN * 2 + TITLE_LINE_HEIGHT * 4, height: TOPBAR_HEIGHT + TITLE_LINE_HEIGHT + NOTE_MARGIN, }; - -/** - * How far a note's edge sits below its own colour: the same hue, a step down - * in value. Used for the card's hairline and the writing area's border. - */ -export const EDGE_VALUE_SCALE = 0.88; diff --git a/blockly-workspace-notes/src/constants/serialization.ts b/blockly-workspace-notes/src/constants/serialization.ts new file mode 100644 index 0000000..7d4a3a6 --- /dev/null +++ b/blockly-workspace-notes/src/constants/serialization.ts @@ -0,0 +1,34 @@ +/** + * @fileoverview The names this plugin claims in Blockly's registries, and the + * version of the format it persists. + * + * These are grouped because they are the plugin's public identifiers: change + * one and existing save files, or another plugin's registrations, are affected. + * Everything else in `constants/` only affects how a note looks. + */ + +/** + * The name the note serializer registers under. This doubles as the top-level + * key in the JSON produced by `Blockly.serialization.workspaces.save()`. + */ +export const NOTE_SERIALIZER_NAME = 'workspaceNotes'; + +/** + * The name of Blockly's built-in workspace comment serializer, which we + * replace so that notes are not saved twice. + */ +export const COMMENT_SERIALIZER_NAME = 'workspaceComments'; + +/** + * Current version of the `workspaceNotes` payload. Bump this whenever the + * shape changes, and add a matching entry to MIGRATIONS in + * `serialization/migrations.ts`. + */ +export const SCHEMA_VERSION = 1; + +/** + * The type string of the custom event used to make note-specific properties + * undoable. Namespaced to avoid colliding with other plugins in the global + * event registry. + */ +export const NOTE_CHANGE_EVENT_TYPE = 'workspace_note_change'; diff --git a/blockly-workspace-notes/src/events.ts b/blockly-workspace-notes/src/events/note_change.ts similarity index 86% rename from blockly-workspace-notes/src/events.ts rename to blockly-workspace-notes/src/events/note_change.ts index 1e8d428..bf71b4f 100644 --- a/blockly-workspace-notes/src/events.ts +++ b/blockly-workspace-notes/src/events/note_change.ts @@ -10,8 +10,9 @@ import * as Blockly from 'blockly/core'; -import type {NoteChangeJson, NoteProperty, NotePropertyValue} from './types'; -import {NOTE_CHANGE_EVENT_TYPE} from './constants'; +import {NOTE_CHANGE_EVENT_TYPE} from '../constants/serialization'; +import type {NoteChangeJson} from '../types/events'; +import type {NoteProperty, NotePropertyValue} from '../types/note'; /** * The part of a note this event needs in order to replay itself. @@ -128,20 +129,3 @@ export class NoteChange extends Blockly.Events.CommentBase { ); } } - -/** - * Registers NoteChange so `Blockly.Events.fromJson` can rebuild it. Safe to - * call repeatedly; re-registering the identical class is a no-op in Blockly's - * registry, and a duplicate-name throw would only mean it is already present. - */ -export function registerNoteChangeEvent(): void { - try { - Blockly.registry.register( - Blockly.registry.Type.EVENT, - NOTE_CHANGE_EVENT_TYPE, - NoteChange, - ); - } catch { - // Already registered by another copy of the plugin. - } -} diff --git a/blockly-workspace-notes/src/events/registry.ts b/blockly-workspace-notes/src/events/registry.ts new file mode 100644 index 0000000..7823f78 --- /dev/null +++ b/blockly-workspace-notes/src/events/registry.ts @@ -0,0 +1,28 @@ +/** + * @fileoverview Teaching Blockly's event registry about `NoteChange`. + * + * Separate from the event itself so that importing the class — to construct + * one, or to name it in a type — never has the side effect of registering it. + */ + +import * as Blockly from 'blockly/core'; + +import {NOTE_CHANGE_EVENT_TYPE} from '../constants/serialization'; +import {NoteChange} from './note_change'; + +/** + * Registers NoteChange so `Blockly.Events.fromJson` can rebuild it. Safe to + * call repeatedly; re-registering the identical class is a no-op in Blockly's + * registry, and a duplicate-name throw would only mean it is already present. + */ +export function registerNoteChangeEvent(): void { + try { + Blockly.registry.register( + Blockly.registry.Type.EVENT, + NOTE_CHANGE_EVENT_TYPE, + NoteChange, + ); + } catch { + // Already registered by another copy of the plugin. + } +} diff --git a/blockly-workspace-notes/src/index.ts b/blockly-workspace-notes/src/index.ts index c4bfb68..91c708d 100644 --- a/blockly-workspace-notes/src/index.ts +++ b/blockly-workspace-notes/src/index.ts @@ -8,6 +8,11 @@ * saved under their own versioned `workspaceNotes` key alongside `blocks`, and * older `workspaceComments` files still load. * + * This file is the package's whole public surface and holds no logic of its + * own: anything not re-exported here is internal, and free to move. It is also + * the build entry point, which `@blockly/dev-scripts` resolves by path, so it + * stays at `src/index.ts`. + * * @example * import * as Blockly from 'blockly'; * import {WorkspaceNotes} from '@mit-app-inventor/blockly-workspace-notes'; @@ -17,214 +22,38 @@ * notes.init(); */ -import * as Blockly from 'blockly/core'; - -import './css'; -import { - registerNoteContextMenu, - unregisterNoteContextMenu, -} from './context_menu'; -import type {SavedNote, WorkspaceNotesOptions} from './types'; -import {DEFAULT_PALETTE, DEFAULT_SIZE} from './constants'; -import {Note, NoteComment, isNote, nextZIndex} from './note'; -import {registerNoteChangeEvent} from './events'; -import {registerNotePaster, unregisterNotePaster} from './paster'; -import { - createNote as makeNote, - registerNoteSerializers, - unregisterNoteSerializers, -} from './serializer'; -import {registerXmlSupport, unregisterXmlSupport} from './xml'; - -/** - * Adds note support to a workspace. - */ -export class WorkspaceNotes { - /** - * @param workspace The workspace to add notes to. A - * headless workspace works too; it simply gets unrendered notes. - * @param {{ - * palette?: !Array<{name: string, hue: number, fill: string}>, - * defaultSize?: {width: number, height: number}, - * getAuthor?: function(): string, - * contextMenu?: boolean, - * skipSerializerRegistration?: boolean, - * emitLegacyComments?: boolean, - * xmlSupport?: boolean, - * }} [options] Plugin options. `skipSerializerRegistration` leaves - * persistence entirely to the host app; `emitLegacyComments` keeps - * writing the old `workspaceComments` key as well, which duplicates - * every note and is off by default; `xmlSupport` wraps the - * `Blockly.Xml` entry points so notes survive the older XML format - * too, and can be turned off by a host that only uses JSON. - */ - /** The workspace this instance is attached to. */ - protected workspace: Blockly.Workspace; - - /** The options this instance was constructed with, over the defaults. */ - protected options: Required; - - /** The workspace's own `newComment`, while ours is in its place. */ - private originalNewComment_: - ((id?: string) => Blockly.comments.WorkspaceComment) | null = null; - - /** Whether `init` has run, so it stays idempotent. */ - private initialized_ = false; - - /** - * @param workspace The workspace to add notes to. A headless workspace works - * too; it simply gets unrendered notes. - * @param options Plugin options. - */ - constructor( - workspace: Blockly.Workspace, - options: WorkspaceNotesOptions = {}, - ) { - this.workspace = workspace; - this.options = { - palette: DEFAULT_PALETTE, - defaultSize: DEFAULT_SIZE, - contextMenu: true, - skipSerializerRegistration: false, - emitLegacyComments: false, - xmlSupport: true, - getAuthor: () => '', - ...options, - }; - } - - /** - * Starts the plugin. - */ - init(): void { - if (this.initialized_) return; - this.initialized_ = true; - - // Blockly's own default is sized for a comment whose whole chrome is a - // 24px bar; a note's margins would leave that barely two lines. - const {width, height} = this.options.defaultSize; - Blockly.comments.CommentView.defaultCommentSize = new Blockly.utils.Size( - width, - height, - ); - - // Patched on the instance rather than the prototype: scoped to this - // workspace and trivially reversible. This is what makes undoing a delete - // rebuild a Note — core's CommentCreate replays through - // `workspace.newComment()`. - this.originalNewComment_ = this.workspace.newComment; - this.workspace.newComment = (id) => makeNote(this.workspace, id); - - registerNoteChangeEvent(); - - if (!this.options.skipSerializerRegistration) { - registerNoteSerializers({ - emitLegacyComments: this.options.emitLegacyComments, - }); - } - - registerNotePaster(); - - if (this.options.xmlSupport) { - registerXmlSupport(); - } - - if (this.options.contextMenu) { - registerNoteContextMenu({palette: this.options.palette}); - } - } - - /** - * Stops the plugin and restores everything it replaced. - * - * The serializer, paster and context menu registries are global singletons - * shared by every workspace, so they are reference-counted and only really - * restored once the last plugin instance is disposed. - */ - dispose(): void { - if (!this.initialized_) return; - this.initialized_ = false; - - if (this.originalNewComment_) { - this.workspace.newComment = this.originalNewComment_; - this.originalNewComment_ = null; - } - - if (!this.options.skipSerializerRegistration) { - unregisterNoteSerializers(); - } - - unregisterNotePaster(); +// The plugin itself: what a host application constructs. +export {WorkspaceNotes} from './plugin'; - if (this.options.xmlSupport) { - unregisterXmlSupport(); - } +// The note classes, and how to recognise and order them. +export {Note} from './model/note'; +export {NoteComment} from './model/note_comment'; +export {restackNotes} from './model/stacking'; +export {isNote} from './utils/guards'; - if (this.options.contextMenu) { - unregisterNoteContextMenu(); - } - } +// Persistence, for a host that drives saving and loading itself. +export {LegacyCommentAdapter} from './serialization/legacy_comment_adapter'; +export {migrate} from './serialization/migrations'; +export {NoteSerializer} from './serialization/note_serializer'; +export {appendNote, saveNote} from './serialization/state'; +export {domToNote, domToNoteState, noteToDom} from './serialization/xml/dom'; - /** - * Creates a note on the workspace. - * - * @param state Initial values for the note. - * @returns The new note. - */ - createNote(state: Partial = {}): Note | NoteComment { - const existingGroup = Blockly.Events.getGroup(); - if (!existingGroup) Blockly.Events.setGroup(true); - try { - const note = makeNote(this.workspace); - if (state.text) note.setText(state.text); - if (state.title) note.setTitle(state.title); - if (state.colour) note.setColour(state.colour); - if (state.x !== undefined || state.y !== undefined) { - note.moveTo(new Blockly.utils.Coordinate(state.x ?? 0, state.y ?? 0)); - } - note.setZIndex(nextZIndex(this.workspace)); - note.restoreMeta({author: this.options.getAuthor()}); - return note; - } finally { - Blockly.Events.setGroup(existingGroup); - } - } +// The undo event and the paster, for a host extending either. +export {NoteChange} from './events/note_change'; +export {NotePaster} from './clipboard/note_paster'; - /** - * @returns Every note on the workspace. - */ - getNotes(): Array { - return this.workspace.getTopComments(false).filter(isNote); - } -} +// The handful of constants a host is expected to read. +export {DEFAULT_COLOUR, DEFAULT_PALETTE} from './constants/colours'; +export {NOTE_SERIALIZER_NAME, SCHEMA_VERSION} from './constants/serialization'; +export type {NoteCopyData} from './types/clipboard'; +export type {NoteChangeJson} from './types/events'; export type { - NoteChangeJson, - NoteCopyData, NoteMeta, NoteProperty, NotePropertyValue, NoteState, - NotesPayload, - PaletteEntry, - SavedNote, - WorkspaceNotesOptions, -} from './types'; -export type {NoteSurface} from './note'; -export {Note, NoteComment, isNote, restackNotes} from './note'; -export {NoteChange} from './events'; -export {NotePaster} from './paster'; -export { - LegacyCommentAdapter, - NoteSerializer, - appendNote, - migrate, - saveNote, -} from './serializer'; -export {domToNote, domToNoteState, noteToDom} from './xml'; -export { - DEFAULT_COLOUR, - DEFAULT_PALETTE, - NOTE_SERIALIZER_NAME, - SCHEMA_VERSION, -} from './constants'; + NoteSurface, +} from './types/note'; +export type {NotesPayload, SavedNote} from './types/serialization'; +export type {PaletteEntry, WorkspaceNotesOptions} from './types/options'; diff --git a/blockly-workspace-notes/src/model/note.ts b/blockly-workspace-notes/src/model/note.ts new file mode 100644 index 0000000..6b2c09c --- /dev/null +++ b/blockly-workspace-notes/src/model/note.ts @@ -0,0 +1,337 @@ +/** + * @fileoverview A note on a rendered workspace: the chrome drawn over the + * state `model/note_mixin.ts` defines. + * + * Everything here is SVG the note adds to, or takes away from, the comment + * core already built — the card, the title and the rule under it — plus the + * plumbing that keeps that chrome in step with a comment core is resizing, + * collapsing and dragging underneath it. + */ + +import * as Blockly from 'blockly/core'; + +import { + NOTE_CLASS, + PINNED_CLASS, + RULE_CLASS, + TITLED_CLASS, + TITLE_CLASS, + UNTITLED_TITLE_TEXT, +} from '../constants/dom'; +import { + FRAME_RADIUS, + MIN_SIZE, + NOTE_MARGIN, + TITLE_RULE_Y, + TOPBAR_HEIGHT, +} from '../constants/layout'; +import type {NoteCopyData} from '../types/clipboard'; +import {edgeFor} from '../utils/colour'; +import {editTitle} from '../ui/title_editor'; +import {RenderedNoteBase} from './note_mixin'; +import {restackNotes} from './stacking'; + +/** + * A note on a rendered workspace. + */ +export class Note extends RenderedNoteBase { + /** + * The paper card behind the note's chrome. + * + * Every field here is optional, and that is not defensiveness: they are + * assigned after `super()` returns, and `super()` can call back into + * `renderTitle()` and `renderColour()`, which read them. The guard in + * `renderTitle` exists for exactly that window. + */ + private card_?: SVGRectElement | null; + + /** The rule under the title. */ + private rule_?: SVGLineElement; + + /** Where a press on the title started, while one is in progress. */ + private titlePressPoint_?: {x: number; y: number} | null; + + /** The SVG text element holding the title. */ + private titleElement_?: SVGTextElement; + + /** The text node inside `titleElement_`. */ + private titleNode_?: Text; + + /** Watches the comment's size so the chrome can follow it. */ + private sizeObserver_?: MutationObserver; + + /** + * @param workspace The workspace to add the note to. + * @param id An optional ID; generated when omitted. + */ + constructor(workspace: Blockly.WorkspaceSvg, id?: string) { + super(workspace, id); + + const root = this.getSvgRoot(); + Blockly.utils.dom.addClass(root, NOTE_CLASS); + const topBar = root.querySelector('.blocklyCommentTopbar'); + + /** + * The card itself: core's own highlight rect, which a note fills rather + * than drawing paper of its own. Core resizes it on every pointer move of + * a drag and strokes it when the note is selected, so borrowing it keeps + * both for free. + * + * The corners are set once. Core only ever writes height, width and x to + * this rect, so the radii survive every resize. + * @private + */ + this.card_ = root.querySelector('.blocklyCommentHighlight'); + this.card_?.setAttribute('rx', `${FRAME_RADIUS}`); + this.card_?.setAttribute('ry', `${FRAME_RADIUS}`); + + /** + * The hairline under the title. + * + * The heading needs separating from the body, and a box around the body + * is the one thing that cannot do it: a lighter bordered panel inset in a + * coloured body is exactly how Blockly draws a field on a block, so a + * note built that way reads as a block however it is shaped. A rule reads + * as an index card instead. + * @private + */ + this.rule_ = Blockly.utils.dom.createSvgElement(Blockly.utils.Svg.LINE, { + 'class': RULE_CLASS, + }); + if (this.card_) { + root.insertBefore(this.rule_, this.card_.nextSibling); + } else { + root.appendChild(this.rule_); + } + + /** + * Where the pointer went down on the title, so a press that turns into a + * drag can be told apart from a click. + * @private + */ + this.titlePressPoint_ = null; + + /** + * The SVG text element showing the title above the writing area. + * @private + */ + this.titleElement_ = Blockly.utils.dom.createSvgElement( + Blockly.utils.Svg.TEXT, + {'class': `${TITLE_CLASS} blocklyText`}, + topBar, + ); + + /** + * The text node holding the (possibly truncated) title. + * @private + */ + this.titleNode_ = document.createTextNode(''); + this.titleElement_.appendChild(this.titleNode_); + + // A field on a block opens its editor on a single click, and the title + // does the same. Gesture decides a field click by checking the press + // never travelled further than the drag radius, so this repeats that test + // rather than claiming every press: dragging a note by its title has to + // keep working. + // + // Bound directly rather than through browserEvents.conditionalBind, which + // gates on Blockly's own touch handling. + this.titleElement_.addEventListener('pointerdown', (e) => { + this.titlePressPoint_ = {x: e.clientX, y: e.clientY}; + }); + + this.titleElement_.addEventListener('pointerup', (e) => { + const press = this.titlePressPoint_; + this.titlePressPoint_ = null; + if (!press || !this.isEditable()) return; + const travelled = Math.hypot(e.clientX - press.x, e.clientY - press.y); + if (travelled > Blockly.config.dragRadius) return; + + // Deferred by a task, and deliberately not stopped. Core's gesture ends + // on this same release and focuses the note's root, so an editor opened + // inline would have focus taken straight back off it; and Gesture binds + // the release on the document, so stopping the event here would leave + // that gesture running. + setTimeout(() => editTitle(this), 0); + }); + + /** + * Watches the view's own width and height attributes. + * + * Core resizes through `setSizeWithoutFiringEvents` on every pointer move + * of a resize drag and only fires its size listeners once, on pointer up. + * Laying the title out from those listeners alone would leave it + * truncated to the old width for the whole drag, so track the attributes + * core writes instead - they change on every move. + * @private + */ + this.sizeObserver_ = new MutationObserver(() => this.renderChrome()); + this.sizeObserver_.observe(root, { + attributes: true, + attributeFilter: ['width', 'height'], + }); + this.view.addDisposeListener(() => this.sizeObserver_?.disconnect()); + + this.view.addOnCollapseListener(() => this.renderChrome()); + + this.renderColour(); + this.renderChrome(); + + // Every size a note is ever given passes through here: core's resize drag + // calls this on each pointer move, `setSize` calls it, and so does + // collapsing (with the stored size, which is why clamping cannot disturb + // it). Core's own floor is computed in a private method a plugin has no + // business replacing, and it is the wrong floor anyway - see MIN_SIZE. + const setViewSize = this.view.setSizeWithoutFiringEvents.bind(this.view); + this.view.setSizeWithoutFiringEvents = (size: Blockly.utils.Size) => { + setViewSize( + Blockly.utils.Size.max( + size, + new Blockly.utils.Size(MIN_SIZE.width, MIN_SIZE.height), + ), + ); + }; + + // Core sizes the note once from its own constructor, and derives the + // writing area's offset there from the measured height of the top bar + // rect. That measurement happens before `super()` returns, which is + // before this constructor can add NOTE_CLASS - so the rect is still core's + // own 24px bar, and the body is left starting a title row too high, + // overlapping the title and crossing the rule. It corrected itself on the + // first resize, collapse or keystroke, which is what made it look like a + // rendering glitch rather than a wrong number. + // + // The class is on the root by now, so one more size pass measures 48 and + // puts the body under the rule. Without firing events: the note is still + // being constructed, and a size change nobody made does not belong on the + // undo stack. + this.view.setSizeWithoutFiringEvents(this.view.getSize()); + } + + /** + * Redraws everything this plugin lays out over core's comment view: the + * card's height and the title. + */ + renderChrome() { + if (this.isDeadOrDying()) return; + this.renderCard(); + this.renderRule(); + this.renderTitle(); + } + + /** + * Shrinks the card to the top bar when the note is collapsed. + * + * Core leaves its highlight rect at the expanded size whatever the + * collapsed state - `updateHighlightRect` is always passed `this.size` - + * because the rect is `fill: none` for a plain comment and therefore never + * seen. A note fills it, so left alone a collapsed note would sit there as + * a full-height card with an empty body. `view.getSize()` is the + * collapse-aware measurement, so the height is taken from that instead. + */ + renderCard() { + if (!this.card_) return; + this.card_.setAttribute('height', `${this.view.getSize().height}`); + } + + /** + * Stretches the hairline to the width of the note. + * + * It is inset to the title's own gutter rather than running edge to edge, + * so it starts where the heading starts. The stylesheet hides it on a + * collapsed note, where there is no body left to divide it from. + */ + renderRule() { + if (!this.rule_) return; + const {width} = this.view.getSize(); + const dir = this.workspace.RTL ? -1 : 1; + const inset = Math.min(NOTE_MARGIN, width / 2); + this.rule_.setAttribute('x1', `${dir * inset}`); + this.rule_.setAttribute('x2', `${dir * Math.max(inset, width - inset)}`); + this.rule_.setAttribute('y1', `${TITLE_RULE_Y}`); + this.rule_.setAttribute('y2', `${TITLE_RULE_Y}`); + } + + /** + * Paints the note by overriding the CSS custom properties core's comment + * stylesheet already reads, which avoids restyling its elements directly. + * + * Two values off the one stored colour: the paper, and the edge that draws + * both the card's outline and the rule under the title. The text is written + * straight onto the paper, so there is no third. + */ + renderColour() { + const colour = this.getColour(); + const style = this.getSvgRoot().style; + style.setProperty('--commentFillColour', colour); + style.setProperty('--commentBorderColour', edgeFor(colour)); + } + + /** + * Draws the title, truncated to the width of the note. + * + * Called on every size change as well as every title change, since the + * truncation depends on both. + */ + renderTitle() { + if (!this.titleElement_ || this.isDeadOrDying()) return; + + const named = !!this.getTitle(); + Blockly.utils.dom[named ? 'addClass' : 'removeClass']( + this.getSvgRoot(), + TITLED_CLASS, + ); + + // An unnamed note still shows a title row - the placeholder is what says + // the row can be clicked - so there is always text to lay out. The + // stylesheet greys it. + const title = named ? this.getTitle() : UNTITLED_TITLE_TEXT; + if (!this.titleNode_ || !this.titleElement_) return; + this.titleNode_.textContent = title; + + this.titleElement_.setAttribute( + 'x', + `${this.workspace.RTL ? -NOTE_MARGIN : NOTE_MARGIN}`, + ); + this.titleElement_.setAttribute('y', `${TOPBAR_HEIGHT / 2}`); + + // Trim a character at a time; titles are short, so this settles fast. + const maxWidth = Math.max(0, this.view.getSize().width - NOTE_MARGIN * 2); + let text = title; + while ( + text.length > 1 && + Blockly.utils.dom.getTextWidth(this.titleElement_) > maxWidth + ) { + text = text.slice(0, -1); + this.titleNode_.textContent = `${text}\u2026`; + } + } + + /** Locks or unlocks the note, and marks it visually. */ + applyPinned() { + super.applyPinned(); + const pinned = this.isPinned(); + Blockly.utils.dom[pinned ? 'addClass' : 'removeClass']( + this.getSvgRoot(), + PINNED_CLASS, + ); + if (pinned) this.applyZIndex(); + } + + /** Restacks every note on the workspace to match their z-indices. */ + applyZIndex() { + restackNotes(this.workspace); + } + + /** + * Includes the note's extra state in clipboard data so that duplicate and + * paste keep the title and colour. Consumed by NotePaster. + * @returns The copy data, or null if the note is not copyable. + */ + toCopyData(): NoteCopyData | null { + const data = super.toCopyData() as NoteCopyData | null; + if (!data) return null; + data.noteState = this.saveNoteState(); + return data; + } +} diff --git a/blockly-workspace-notes/src/model/note_comment.ts b/blockly-workspace-notes/src/model/note_comment.ts new file mode 100644 index 0000000..8ce15d1 --- /dev/null +++ b/blockly-workspace-notes/src/model/note_comment.ts @@ -0,0 +1,14 @@ +/** + * @fileoverview A note on a headless workspace. + * + * This is the class a Node test gets, and the one the serializers build when + * no renderer exists. It carries the whole note state and none of the chrome; + * `model/note.ts` is the same thing with a card drawn around it. + */ + +import {NoteCommentBase} from './note_mixin'; + +/** + * A note on a headless workspace: all of the state, none of the rendering. + */ +export class NoteComment extends NoteCommentBase {} diff --git a/blockly-workspace-notes/src/model/note_mixin.ts b/blockly-workspace-notes/src/model/note_mixin.ts new file mode 100644 index 0000000..0f7c417 --- /dev/null +++ b/blockly-workspace-notes/src/model/note_mixin.ts @@ -0,0 +1,322 @@ +/** + * @fileoverview The note behaviour, as a mixin over a workspace comment class. + * + * Blockly keeps its comment model and its rendered comment in a single + * inheritance chain (`WorkspaceComment` -> `RenderedWorkspaceComment`), and a + * headless workspace only ever produces the former. The extra state is + * therefore expressed as a mixin and applied to both, so notes work + * identically in a browser and in a headless workspace (which is all a Node + * test can build — `Blockly.inject` is unavailable there). + * + * Everything here works without a DOM. The four `render*`/`apply*` methods are + * the seam: they are no-ops at this level, and `model/note.ts` overrides them + * to paint the same state onto SVG. + */ + +import * as Blockly from 'blockly/core'; + +import {DEFAULT_COLOUR} from '../constants/colours'; +import {NoteChange} from '../events/note_change'; +import {asOneUndoStep} from '../utils/undo'; +import type { + NoteMeta, + NoteProperty, + NotePropertyValue, + NoteState, + NoteSurface, +} from '../types/note'; + +/** + * Any constructor producing a workspace comment. + * + * `any[]` rather than the real parameters because a mixin cannot know what its + * base takes; the two exported classes below restore the real signature. + */ +type CommentConstructor = new ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...args: any[] +) => Blockly.comments.WorkspaceComment; + +/** + * Adds note state and behaviour to a workspace comment class. + * + * @param Base `WorkspaceComment` or `RenderedWorkspaceComment`. + * @returns The extended class. + */ +const NoteMixin = (Base: TBase) => + class extends Base { + /** + * This note's extra state, created on first access. + * + * Declared without an initializer on purpose. `target: es6` keeps + * `useDefineForClassFields` off, so this declaration emits nothing — which + * matters, because a field definition would run after `super()` and wipe a + * value that the base constructor's create event had already caused to be + * built. + */ + protected noteState_?: NoteState; + + /** + * Returns this note's extra state, creating it on first access. + * + * The state is built lazily rather than in a class field because subclass + * fields are initialized only after `super()` returns, and the + * `WorkspaceComment` constructor already fires a create event and drives + * view setup before that point. + * + * @returns The live state object. Treat as read-only. + */ + getNoteState(): NoteState { + if (!this.noteState_) { + const now = new Date().toISOString(); + this.noteState_ = { + title: '', + colour: DEFAULT_COLOUR, + pinned: false, + zIndex: 0, + meta: {author: '', createdAt: now, updatedAt: now}, + }; + } + return this.noteState_; + } + + /** @returns The note's title, or '' if it has none. */ + getTitle(): string { + return this.getNoteState().title; + } + + /** + * Sets the note's title. + * @param title The new title. + */ + setTitle(title: string): void { + this.changeNoteProperty_('title', String(title ?? '')); + } + + /** @returns The note's background colour as a hex string. */ + getColour(): string { + return this.getNoteState().colour; + } + + /** + * Sets the note's background colour. + * @param colour A CSS colour; parsed to hex via Blockly. + */ + setColour(colour: string): void { + const parsed = Blockly.utils.colour.parse(colour) ?? DEFAULT_COLOUR; + this.changeNoteProperty_('colour', parsed); + } + + /** @returns Whether the note is pinned. */ + isPinned(): boolean { + return this.getNoteState().pinned; + } + + /** + * Pins or unpins the note. A pinned note is locked in place and kept in + * front of its neighbours. + * @param pinned Whether the note should be pinned. + */ + setPinned(pinned: boolean): void { + this.changeNoteProperty_('pinned', !!pinned); + } + + /** @returns The note's stacking order; higher is nearer front. */ + getZIndex(): number { + return this.getNoteState().zIndex; + } + + /** + * Sets the note's stacking order. + * @param zIndex The new stacking order. + */ + setZIndex(zIndex: number): void { + this.changeNoteProperty_('zIndex', Number(zIndex) || 0); + } + + /** @returns A copy of the note's metadata. */ + getMeta(): NoteMeta { + return {...this.getNoteState().meta}; + } + + /** + * Replaces the note's metadata wholesale, without firing an event or + * bumping `updatedAt`. Used when loading, so a round-trip preserves + * timestamps verbatim. + * @param meta The metadata to restore. + */ + restoreMeta(meta: Partial): void { + this.getNoteState().meta = {...this.getNoteState().meta, ...meta}; + } + + /** Records that the note changed just now. */ + touchMeta(): void { + this.getNoteState().meta.updatedAt = new Date().toISOString(); + } + + /** + * Returns a plain, JSON-serializable copy of every note-specific field. + * @returns The note's extra state. + */ + saveNoteState(): NoteState { + const state = this.getNoteState(); + return { + title: state.title, + colour: state.colour, + pinned: state.pinned, + zIndex: state.zIndex, + meta: {...state.meta}, + }; + } + + /** + * Applies a property change without firing an event. + * + * This is the single write path: `changeNoteProperty_` uses it for user + * edits, and {@link NoteChange} uses it to replay undo and redo. + * + * @param property The property name, or '*' for a whole state + * object. + * @param value The value to apply. + */ + applyNoteProperty(property: NoteProperty, value?: NotePropertyValue): void { + const state = this.getNoteState(); + // The property name is what says which shape `value` has — a + // correspondence the type system cannot see across two independent + // parameters, so each case asserts the one it knows it has. + switch (property) { + case 'title': + state.title = value as string; + this.renderTitle(); + break; + case 'colour': + state.colour = value as string; + this.renderColour(); + break; + case 'pinned': + state.pinned = value as boolean; + this.applyPinned(); + break; + case 'zIndex': + state.zIndex = value as number; + this.applyZIndex(); + break; + case 'meta': + state.meta = {...(value as NoteMeta)}; + break; + case '*': { + // Null is the "forward" half of a delete snapshot: there is nothing + // to restore when replaying towards the deletion. + if (!value) break; + const whole = value as NoteState; + state.title = whole.title; + state.colour = whole.colour; + state.pinned = whole.pinned; + state.zIndex = whole.zIndex; + state.meta = {...whole.meta}; + this.renderTitle(); + this.renderColour(); + this.applyPinned(); + this.applyZIndex(); + break; + } + default: + console.warn(`Unknown note property: ${property}`); + } + } + + /** + * Reads a single note property. + * @param property The property name, or '*'. + * @returns The current value. + */ + protected readNoteProperty_(property: NoteProperty): NotePropertyValue { + return property === '*' + ? this.saveNoteState() + : this.getNoteState()[property]; + } + + /** + * Applies a change and fires an undoable event describing it. + * @param property The property name. + * @param value The new value. + */ + protected changeNoteProperty_( + property: NoteProperty, + value: NotePropertyValue, + ): void { + const oldValue = this.readNoteProperty_(property); + if (JSON.stringify(oldValue) === JSON.stringify(value)) return; + + this.applyNoteProperty(property, value); + this.touchMeta(); + + if (Blockly.Events.isEnabled()) { + Blockly.Events.fire(new NoteChange(this, property, oldValue, value)); + } + } + + /** Reflects the title in the DOM. Overridden by the rendered subclass. */ + renderTitle(): void {} + + /** Reflects the colour in the DOM. Overridden by the rendered subclass. */ + renderColour(): void {} + + /** Applies the pinned flag. Locking works headlessly too. */ + applyPinned(): void { + this.setMovable(!this.getNoteState().pinned); + } + + /** Applies the stacking order. Overridden by the rendered subclass. */ + applyZIndex(): void {} + + /** + * Disposes of the note. + * + * A snapshot event is fired *before* the delete so that undoing a deletion + * restores the extra fields: a group is undone in reverse, so core's + * `CommentCreate` rebuilds the bare note first and this event then + * repaints it. Core's delete event only carries the fields its own + * serializer knows about. + * + * Both events must share a group, or undo would stop after the first and + * the user would need a second undo to get the colour back. + */ + dispose(): void { + asOneUndoStep(() => { + if (!this.isDeadOrDying() && Blockly.Events.isEnabled()) { + // newValue is null rather than the state: an event whose two sides + // are equal is dropped by isNull() and never reaches the undo stack. + Blockly.Events.fire( + new NoteChange(this, '*', this.saveNoteState(), null), + ); + } + super.dispose(); + }); + } + }; + +/** + * The mixin applied to the headless comment, with its real constructor. + * + * Naming the result is what makes both of the following work: the constructor + * parameters survive (a mixin's base is `...args: any[]`, which would otherwise + * erase them), and `declaration: true` has a type it can write down instead of + * an anonymous class expression. + */ +export const NoteCommentBase = NoteMixin( + Blockly.comments.WorkspaceComment, +) as new ( + workspace: Blockly.Workspace, + id?: string, +) => Blockly.comments.WorkspaceComment & NoteSurface; + +/** + * The mixin applied to the rendered comment. See `NoteCommentBase`. + */ +export const RenderedNoteBase = NoteMixin( + Blockly.comments.RenderedWorkspaceComment, +) as new ( + workspace: Blockly.WorkspaceSvg, + id?: string, +) => Blockly.comments.RenderedWorkspaceComment & NoteSurface; diff --git a/blockly-workspace-notes/src/model/stacking.ts b/blockly-workspace-notes/src/model/stacking.ts new file mode 100644 index 0000000..668a536 --- /dev/null +++ b/blockly-workspace-notes/src/model/stacking.ts @@ -0,0 +1,62 @@ +/** + * @fileoverview Reading and writing the order notes stack in. + * + * A note's z-index is a number it stores; the browser honours DOM order + * instead. `restackNotes` is what reconciles the two, and the two lookups + * below are what a caller uses to put a note in front of, or behind, + * everything already on the workspace. + * + * This module and `model/note.ts` import each other: `Note.applyZIndex` calls + * `restackNotes`, and `restackNotes` needs the class to recognise a note. Both + * uses are inside function bodies, so neither runs during module evaluation + * and the cycle resolves. Keep it that way — a top-level use of `Note` here + * would hit the temporal dead zone. + */ + +import * as Blockly from 'blockly/core'; + +import {Note} from './note'; +import {isNote} from '../utils/guards'; + +/** + * Reorders the notes on a workspace so their DOM order matches their + * z-indices. + * + * @param workspace The workspace to restack. + */ +export function restackNotes(workspace: Blockly.Workspace): void { + if (!workspace.rendered) return; + const notes = workspace + .getTopComments(false) + .filter( + (comment): comment is Note => + comment instanceof Note && !comment.isDeadOrDying(), + ); + notes + .sort((a, b) => a.getZIndex() - b.getZIndex()) + .forEach((note) => note.view.bringToFront()); +} + +/** + * @param workspace The workspace to inspect. + * @returns One more than the highest z-index in use. + */ +export function nextZIndex(workspace: Blockly.Workspace): number { + const zIndices = workspace + .getTopComments(false) + .filter(isNote) + .map((note) => note.getZIndex()); + return zIndices.length ? Math.max(...zIndices) + 1 : 1; +} + +/** + * @param workspace The workspace to inspect. + * @returns One less than the lowest z-index in use. + */ +export function previousZIndex(workspace: Blockly.Workspace): number { + const zIndices = workspace + .getTopComments(false) + .filter(isNote) + .map((note) => note.getZIndex()); + return zIndices.length ? Math.min(...zIndices) - 1 : -1; +} diff --git a/blockly-workspace-notes/src/note.ts b/blockly-workspace-notes/src/note.ts deleted file mode 100644 index e7ef714..0000000 --- a/blockly-workspace-notes/src/note.ts +++ /dev/null @@ -1,724 +0,0 @@ -/** - * @fileoverview Notes: workspace comments that also carry a title, a colour, - * authorship metadata and a stacking order. - * - * Blockly keeps its comment model and its rendered comment in a single - * inheritance chain (`WorkspaceComment` -> `RenderedWorkspaceComment`), and a - * headless workspace only ever produces the former. The extra state is - * therefore expressed as a mixin and applied to both, so notes work - * identically in a browser and in a headless workspace (which is all a Node - * test can build — `Blockly.inject` is unavailable there). - */ - -import * as Blockly from 'blockly/core'; - -import { - DEFAULT_COLOUR, - FRAME_RADIUS, - MIN_SIZE, - NOTE_CLASS, - NOTE_MARGIN, - PINNED_CLASS, - RULE_CLASS, - TITLED_CLASS, - TITLE_CLASS, - TITLE_RULE_Y, - TOPBAR_HEIGHT, - UNTITLED_TITLE_TEXT, -} from './constants'; -import {edgeFor} from './colour'; -import {editTitle} from './title_editor'; -import type { - NoteCopyData, - NoteMeta, - NoteProperty, - NotePropertyValue, - NoteState, -} from './types'; -import {NoteChange} from './events'; - -/** - * Any constructor producing a workspace comment. - * - * `any[]` rather than the real parameters because a mixin cannot know what its - * base takes; the two exported classes below restore the real signature. - */ -type CommentConstructor = new ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ...args: any[] -) => Blockly.comments.WorkspaceComment; - -/** - * Everything the mixin adds to a workspace comment. - * - * Declared separately from the mixin because TypeScript cannot name an - * anonymous class expression in a `.d.ts`. Without this interface, `declaration: - * true` fails on `NoteComment` and `Note` with "has or is using private name". - */ -export interface NoteSurface { - getNoteState(): NoteState; - getTitle(): string; - setTitle(title: string): void; - getColour(): string; - setColour(colour: string): void; - isPinned(): boolean; - setPinned(pinned: boolean): void; - getZIndex(): number; - setZIndex(zIndex: number): void; - getMeta(): NoteMeta; - restoreMeta(meta: Partial): void; - touchMeta(): void; - saveNoteState(): NoteState; - applyNoteProperty(property: NoteProperty, value?: NotePropertyValue): void; - renderTitle(): void; - renderColour(): void; - applyPinned(): void; - applyZIndex(): void; -} - -/** - * Adds note state and behaviour to a workspace comment class. - * - * @param Base `WorkspaceComment` or `RenderedWorkspaceComment`. - * @returns The extended class. - */ -const NoteMixin = (Base: TBase) => - class extends Base { - /** - * This note's extra state, created on first access. - * - * Declared without an initializer on purpose. `target: es6` keeps - * `useDefineForClassFields` off, so this declaration emits nothing — which - * matters, because a field definition would run after `super()` and wipe a - * value that the base constructor's create event had already caused to be - * built. - */ - protected noteState_?: NoteState; - - /** - * Returns this note's extra state, creating it on first access. - * - * The state is built lazily rather than in a class field because subclass - * fields are initialized only after `super()` returns, and the - * `WorkspaceComment` constructor already fires a create event and drives - * view setup before that point. - * - * @returns The live state object. Treat as read-only. - */ - getNoteState(): NoteState { - if (!this.noteState_) { - const now = new Date().toISOString(); - this.noteState_ = { - title: '', - colour: DEFAULT_COLOUR, - pinned: false, - zIndex: 0, - meta: {author: '', createdAt: now, updatedAt: now}, - }; - } - return this.noteState_; - } - - /** @returns The note's title, or '' if it has none. */ - getTitle(): string { - return this.getNoteState().title; - } - - /** - * Sets the note's title. - * @param title The new title. - */ - setTitle(title: string): void { - this.changeNoteProperty_('title', String(title ?? '')); - } - - /** @returns The note's background colour as a hex string. */ - getColour(): string { - return this.getNoteState().colour; - } - - /** - * Sets the note's background colour. - * @param colour A CSS colour; parsed to hex via Blockly. - */ - setColour(colour: string): void { - const parsed = Blockly.utils.colour.parse(colour) ?? DEFAULT_COLOUR; - this.changeNoteProperty_('colour', parsed); - } - - /** @returns Whether the note is pinned. */ - isPinned(): boolean { - return this.getNoteState().pinned; - } - - /** - * Pins or unpins the note. A pinned note is locked in place and kept in - * front of its neighbours. - * @param pinned Whether the note should be pinned. - */ - setPinned(pinned: boolean): void { - this.changeNoteProperty_('pinned', !!pinned); - } - - /** @returns The note's stacking order; higher is nearer front. */ - getZIndex(): number { - return this.getNoteState().zIndex; - } - - /** - * Sets the note's stacking order. - * @param zIndex The new stacking order. - */ - setZIndex(zIndex: number): void { - this.changeNoteProperty_('zIndex', Number(zIndex) || 0); - } - - /** @returns A copy of the note's metadata. */ - getMeta(): NoteMeta { - return {...this.getNoteState().meta}; - } - - /** - * Replaces the note's metadata wholesale, without firing an event or - * bumping `updatedAt`. Used when loading, so a round-trip preserves - * timestamps verbatim. - * @param meta The metadata to restore. - */ - restoreMeta(meta: Partial): void { - this.getNoteState().meta = {...this.getNoteState().meta, ...meta}; - } - - /** Records that the note changed just now. */ - touchMeta(): void { - this.getNoteState().meta.updatedAt = new Date().toISOString(); - } - - /** - * Returns a plain, JSON-serializable copy of every note-specific field. - * @returns The note's extra state. - */ - saveNoteState(): NoteState { - const state = this.getNoteState(); - return { - title: state.title, - colour: state.colour, - pinned: state.pinned, - zIndex: state.zIndex, - meta: {...state.meta}, - }; - } - - /** - * Applies a property change without firing an event. - * - * This is the single write path: `changeNoteProperty_` uses it for user - * edits, and {@link NoteChange} uses it to replay undo and redo. - * - * @param property The property name, or '*' for a whole state - * object. - * @param value The value to apply. - */ - applyNoteProperty(property: NoteProperty, value?: NotePropertyValue): void { - const state = this.getNoteState(); - // The property name is what says which shape `value` has — a - // correspondence the type system cannot see across two independent - // parameters, so each case asserts the one it knows it has. - switch (property) { - case 'title': - state.title = value as string; - this.renderTitle(); - break; - case 'colour': - state.colour = value as string; - this.renderColour(); - break; - case 'pinned': - state.pinned = value as boolean; - this.applyPinned(); - break; - case 'zIndex': - state.zIndex = value as number; - this.applyZIndex(); - break; - case 'meta': - state.meta = {...(value as NoteMeta)}; - break; - case '*': { - // Null is the "forward" half of a delete snapshot: there is nothing - // to restore when replaying towards the deletion. - if (!value) break; - const whole = value as NoteState; - state.title = whole.title; - state.colour = whole.colour; - state.pinned = whole.pinned; - state.zIndex = whole.zIndex; - state.meta = {...whole.meta}; - this.renderTitle(); - this.renderColour(); - this.applyPinned(); - this.applyZIndex(); - break; - } - default: - console.warn(`Unknown note property: ${property}`); - } - } - - /** - * Reads a single note property. - * @param property The property name, or '*'. - * @returns The current value. - */ - protected readNoteProperty_(property: NoteProperty): NotePropertyValue { - return property === '*' - ? this.saveNoteState() - : this.getNoteState()[property]; - } - - /** - * Applies a change and fires an undoable event describing it. - * @param property The property name. - * @param value The new value. - */ - protected changeNoteProperty_( - property: NoteProperty, - value: NotePropertyValue, - ): void { - const oldValue = this.readNoteProperty_(property); - if (JSON.stringify(oldValue) === JSON.stringify(value)) return; - - this.applyNoteProperty(property, value); - this.touchMeta(); - - if (Blockly.Events.isEnabled()) { - Blockly.Events.fire(new NoteChange(this, property, oldValue, value)); - } - } - - /** Reflects the title in the DOM. Overridden by the rendered subclass. */ - renderTitle(): void {} - - /** Reflects the colour in the DOM. Overridden by the rendered subclass. */ - renderColour(): void {} - - /** Applies the pinned flag. Locking works headlessly too. */ - applyPinned(): void { - this.setMovable(!this.getNoteState().pinned); - } - - /** Applies the stacking order. Overridden by the rendered subclass. */ - applyZIndex(): void {} - - /** - * Disposes of the note. - * - * A snapshot event is fired *before* the delete so that undoing a deletion - * restores the extra fields: a group is undone in reverse, so core's - * `CommentCreate` rebuilds the bare note first and this event then - * repaints it. Core's delete event only carries the fields its own - * serializer knows about. - * - * Both events must share a group, or undo would stop after the first and - * the user would need a second undo to get the colour back. - */ - dispose(): void { - const existingGroup = Blockly.Events.getGroup(); - if (!existingGroup) Blockly.Events.setGroup(true); - try { - if (!this.isDeadOrDying() && Blockly.Events.isEnabled()) { - // newValue is null rather than the state: an event whose two sides - // are equal is dropped by isNull() and never reaches the undo stack. - Blockly.Events.fire( - new NoteChange(this, '*', this.saveNoteState(), null), - ); - } - super.dispose(); - } finally { - Blockly.Events.setGroup(existingGroup); - } - } - }; - -/** - * The mixin applied to the headless comment, with its real constructor. - * - * Naming the result is what makes both of the following work: the constructor - * parameters survive (a mixin's base is `...args: any[]`, which would otherwise - * erase them), and `declaration: true` has a type it can write down instead of - * an anonymous class expression. - */ -const NoteCommentBase = NoteMixin(Blockly.comments.WorkspaceComment) as new ( - workspace: Blockly.Workspace, - id?: string, -) => Blockly.comments.WorkspaceComment & NoteSurface; - -/** - * A note on a headless workspace: all of the state, none of the rendering. - */ -export class NoteComment extends NoteCommentBase {} - -/** - * The mixin applied to the rendered comment. See `NoteCommentBase`. - */ -const RenderedNoteBase = NoteMixin( - Blockly.comments.RenderedWorkspaceComment, -) as new ( - workspace: Blockly.WorkspaceSvg, - id?: string, -) => Blockly.comments.RenderedWorkspaceComment & NoteSurface; - -/** - * A note on a rendered workspace. - */ -export class Note extends RenderedNoteBase { - /** - * The paper card behind the note's chrome. - * - * Every field here is optional, and that is not defensiveness: they are - * assigned after `super()` returns, and `super()` can call back into - * `renderTitle()` and `renderColour()`, which read them. The guard in - * `renderTitle` exists for exactly that window. - */ - private card_?: SVGRectElement | null; - - /** The rule under the title. */ - private rule_?: SVGLineElement; - - /** Where a press on the title started, while one is in progress. */ - private titlePressPoint_?: {x: number; y: number} | null; - - /** The SVG text element holding the title. */ - private titleElement_?: SVGTextElement; - - /** The text node inside `titleElement_`. */ - private titleNode_?: Text; - - /** Watches the comment's size so the chrome can follow it. */ - private sizeObserver_?: MutationObserver; - - /** - * @param workspace The workspace to add the note to. - * @param id An optional ID; generated when omitted. - */ - constructor(workspace: Blockly.WorkspaceSvg, id?: string) { - super(workspace, id); - - const root = this.getSvgRoot(); - Blockly.utils.dom.addClass(root, NOTE_CLASS); - const topBar = root.querySelector('.blocklyCommentTopbar'); - - /** - * The card itself: core's own highlight rect, which a note fills rather - * than drawing paper of its own. Core resizes it on every pointer move of - * a drag and strokes it when the note is selected, so borrowing it keeps - * both for free. - * - * The corners are set once. Core only ever writes height, width and x to - * this rect, so the radii survive every resize. - * @private - */ - this.card_ = root.querySelector('.blocklyCommentHighlight'); - this.card_?.setAttribute('rx', `${FRAME_RADIUS}`); - this.card_?.setAttribute('ry', `${FRAME_RADIUS}`); - - /** - * The hairline under the title. - * - * The heading needs separating from the body, and a box around the body - * is the one thing that cannot do it: a lighter bordered panel inset in a - * coloured body is exactly how Blockly draws a field on a block, so a - * note built that way reads as a block however it is shaped. A rule reads - * as an index card instead. - * @private - */ - this.rule_ = Blockly.utils.dom.createSvgElement(Blockly.utils.Svg.LINE, { - 'class': RULE_CLASS, - }); - if (this.card_) { - root.insertBefore(this.rule_, this.card_.nextSibling); - } else { - root.appendChild(this.rule_); - } - - /** - * Where the pointer went down on the title, so a press that turns into a - * drag can be told apart from a click. - * @private - */ - this.titlePressPoint_ = null; - - /** - * The SVG text element showing the title above the writing area. - * @private - */ - this.titleElement_ = Blockly.utils.dom.createSvgElement( - Blockly.utils.Svg.TEXT, - {'class': `${TITLE_CLASS} blocklyText`}, - topBar, - ); - - /** - * The text node holding the (possibly truncated) title. - * @private - */ - this.titleNode_ = document.createTextNode(''); - this.titleElement_.appendChild(this.titleNode_); - - // A field on a block opens its editor on a single click, and the title - // does the same. Gesture decides a field click by checking the press - // never travelled further than the drag radius, so this repeats that test - // rather than claiming every press: dragging a note by its title has to - // keep working. - // - // Bound directly rather than through browserEvents.conditionalBind, which - // gates on Blockly's own touch handling. - this.titleElement_.addEventListener('pointerdown', (e) => { - this.titlePressPoint_ = {x: e.clientX, y: e.clientY}; - }); - - this.titleElement_.addEventListener('pointerup', (e) => { - const press = this.titlePressPoint_; - this.titlePressPoint_ = null; - if (!press || !this.isEditable()) return; - const travelled = Math.hypot(e.clientX - press.x, e.clientY - press.y); - if (travelled > Blockly.config.dragRadius) return; - - // Deferred by a task, and deliberately not stopped. Core's gesture ends - // on this same release and focuses the note's root, so an editor opened - // inline would have focus taken straight back off it; and Gesture binds - // the release on the document, so stopping the event here would leave - // that gesture running. - setTimeout(() => editTitle(this), 0); - }); - - /** - * Watches the view's own width and height attributes. - * - * Core resizes through `setSizeWithoutFiringEvents` on every pointer move - * of a resize drag and only fires its size listeners once, on pointer up. - * Laying the title out from those listeners alone would leave it - * truncated to the old width for the whole drag, so track the attributes - * core writes instead - they change on every move. - * @private - */ - this.sizeObserver_ = new MutationObserver(() => this.renderChrome()); - this.sizeObserver_.observe(root, { - attributes: true, - attributeFilter: ['width', 'height'], - }); - this.view.addDisposeListener(() => this.sizeObserver_?.disconnect()); - - this.view.addOnCollapseListener(() => this.renderChrome()); - - this.renderColour(); - this.renderChrome(); - - // Every size a note is ever given passes through here: core's resize drag - // calls this on each pointer move, `setSize` calls it, and so does - // collapsing (with the stored size, which is why clamping cannot disturb - // it). Core's own floor is computed in a private method a plugin has no - // business replacing, and it is the wrong floor anyway - see MIN_SIZE. - const setViewSize = this.view.setSizeWithoutFiringEvents.bind(this.view); - this.view.setSizeWithoutFiringEvents = (size: Blockly.utils.Size) => { - setViewSize( - Blockly.utils.Size.max( - size, - new Blockly.utils.Size(MIN_SIZE.width, MIN_SIZE.height), - ), - ); - }; - - // Core sizes the note once from its own constructor, and derives the - // writing area's offset there from the measured height of the top bar - // rect. That measurement happens before `super()` returns, which is - // before this constructor can add NOTE_CLASS - so the rect is still core's - // own 24px bar, and the body is left starting a title row too high, - // overlapping the title and crossing the rule. It corrected itself on the - // first resize, collapse or keystroke, which is what made it look like a - // rendering glitch rather than a wrong number. - // - // The class is on the root by now, so one more size pass measures 48 and - // puts the body under the rule. Without firing events: the note is still - // being constructed, and a size change nobody made does not belong on the - // undo stack. - this.view.setSizeWithoutFiringEvents(this.view.getSize()); - } - - /** - * Redraws everything this plugin lays out over core's comment view: the - * card's height and the title. - */ - renderChrome() { - if (this.isDeadOrDying()) return; - this.renderCard(); - this.renderRule(); - this.renderTitle(); - } - - /** - * Shrinks the card to the top bar when the note is collapsed. - * - * Core leaves its highlight rect at the expanded size whatever the - * collapsed state - `updateHighlightRect` is always passed `this.size` - - * because the rect is `fill: none` for a plain comment and therefore never - * seen. A note fills it, so left alone a collapsed note would sit there as - * a full-height card with an empty body. `view.getSize()` is the - * collapse-aware measurement, so the height is taken from that instead. - */ - renderCard() { - if (!this.card_) return; - this.card_.setAttribute('height', `${this.view.getSize().height}`); - } - - /** - * Stretches the hairline to the width of the note. - * - * It is inset to the title's own gutter rather than running edge to edge, - * so it starts where the heading starts. The stylesheet hides it on a - * collapsed note, where there is no body left to divide it from. - */ - renderRule() { - if (!this.rule_) return; - const {width} = this.view.getSize(); - const dir = this.workspace.RTL ? -1 : 1; - const inset = Math.min(NOTE_MARGIN, width / 2); - this.rule_.setAttribute('x1', `${dir * inset}`); - this.rule_.setAttribute('x2', `${dir * Math.max(inset, width - inset)}`); - this.rule_.setAttribute('y1', `${TITLE_RULE_Y}`); - this.rule_.setAttribute('y2', `${TITLE_RULE_Y}`); - } - - /** - * Paints the note by overriding the CSS custom properties core's comment - * stylesheet already reads, which avoids restyling its elements directly. - * - * Two values off the one stored colour: the paper, and the edge that draws - * both the card's outline and the rule under the title. The text is written - * straight onto the paper, so there is no third. - */ - renderColour() { - const colour = this.getColour(); - const style = this.getSvgRoot().style; - style.setProperty('--commentFillColour', colour); - style.setProperty('--commentBorderColour', edgeFor(colour)); - } - - /** - * Draws the title, truncated to the width of the note. - * - * Called on every size change as well as every title change, since the - * truncation depends on both. - */ - renderTitle() { - if (!this.titleElement_ || this.isDeadOrDying()) return; - - const named = !!this.getTitle(); - Blockly.utils.dom[named ? 'addClass' : 'removeClass']( - this.getSvgRoot(), - TITLED_CLASS, - ); - - // An unnamed note still shows a title row - the placeholder is what says - // the row can be clicked - so there is always text to lay out. The - // stylesheet greys it. - const title = named ? this.getTitle() : UNTITLED_TITLE_TEXT; - if (!this.titleNode_ || !this.titleElement_) return; - this.titleNode_.textContent = title; - - this.titleElement_.setAttribute( - 'x', - `${this.workspace.RTL ? -NOTE_MARGIN : NOTE_MARGIN}`, - ); - this.titleElement_.setAttribute('y', `${TOPBAR_HEIGHT / 2}`); - - // Trim a character at a time; titles are short, so this settles fast. - const maxWidth = Math.max(0, this.view.getSize().width - NOTE_MARGIN * 2); - let text = title; - while ( - text.length > 1 && - Blockly.utils.dom.getTextWidth(this.titleElement_) > maxWidth - ) { - text = text.slice(0, -1); - this.titleNode_.textContent = `${text}\u2026`; - } - } - - /** Locks or unlocks the note, and marks it visually. */ - applyPinned() { - super.applyPinned(); - const pinned = this.isPinned(); - Blockly.utils.dom[pinned ? 'addClass' : 'removeClass']( - this.getSvgRoot(), - PINNED_CLASS, - ); - if (pinned) this.applyZIndex(); - } - - /** Restacks every note on the workspace to match their z-indices. */ - applyZIndex() { - restackNotes(this.workspace); - } - - /** - * Includes the note's extra state in clipboard data so that duplicate and - * paste keep the title and colour. Consumed by NotePaster. - * @returns The copy data, or null if the note is not copyable. - */ - toCopyData(): NoteCopyData | null { - const data = super.toCopyData() as NoteCopyData | null; - if (!data) return null; - data.noteState = this.saveNoteState(); - return data; - } -} - -/** - * Reorders the notes on a workspace so their DOM order matches their - * z-indices. - * - * @param workspace The workspace to restack. - */ -export function restackNotes(workspace: Blockly.Workspace): void { - if (!workspace.rendered) return; - const notes = workspace - .getTopComments(false) - .filter( - (comment): comment is Note => - comment instanceof Note && !comment.isDeadOrDying(), - ); - notes - .sort((a, b) => a.getZIndex() - b.getZIndex()) - .forEach((note) => note.view.bringToFront()); -} - -/** - * @param workspace The workspace to inspect. - * @returns One more than the highest z-index in use. - */ -export function nextZIndex(workspace: Blockly.Workspace): number { - const zIndices = workspace - .getTopComments(false) - .filter(isNote) - .map((note) => note.getZIndex()); - return zIndices.length ? Math.max(...zIndices) + 1 : 1; -} - -/** - * @param workspace The workspace to inspect. - * @returns One less than the lowest z-index in use. - */ -export function previousZIndex(workspace: Blockly.Workspace): number { - const zIndices = workspace - .getTopComments(false) - .filter(isNote) - .map((note) => note.getZIndex()); - return zIndices.length ? Math.min(...zIndices) - 1 : -1; -} - -/** - * @param candidate Any value. - * @returns Whether the value is a note. - */ -export function isNote(candidate: unknown): candidate is Note | NoteComment { - return candidate instanceof Note || candidate instanceof NoteComment; -} diff --git a/blockly-workspace-notes/src/plugin.ts b/blockly-workspace-notes/src/plugin.ts new file mode 100644 index 0000000..fa8542d --- /dev/null +++ b/blockly-workspace-notes/src/plugin.ts @@ -0,0 +1,192 @@ +/** + * @fileoverview `WorkspaceNotes`: the object a host application constructs. + * + * Everything else in the plugin is a piece this class switches on. `init` + * claims the registries — the serializers, the paster, the event, the context + * menu, the `Blockly.Xml` entry points — and `dispose` hands every one of them + * back, so a host can attach notes to a workspace and detach them again + * without leaving Blockly changed. + * + * Importing `ui/css` here is what registers the stylesheet, and it has to + * happen before `Blockly.inject`: `Blockly.Css.register` only affects + * injections that come after it. + */ + +import * as Blockly from 'blockly/core'; + +import './ui/css'; +import {DEFAULT_PALETTE} from './constants/colours'; +import {DEFAULT_SIZE} from './constants/layout'; +import {Note} from './model/note'; +import {NoteComment} from './model/note_comment'; +import {nextZIndex} from './model/stacking'; +import { + registerNotePaster, + unregisterNotePaster, +} from './clipboard/note_paster'; +import {registerNoteChangeEvent} from './events/registry'; +import { + registerNoteSerializers, + unregisterNoteSerializers, +} from './serialization/registry'; +import {createNote as makeNote} from './serialization/state'; +import { + registerXmlSupport, + unregisterXmlSupport, +} from './serialization/xml/patch'; +import type {SavedNote} from './types/serialization'; +import type {WorkspaceNotesOptions} from './types/options'; +import { + registerNoteContextMenu, + unregisterNoteContextMenu, +} from './ui/context_menu'; +import {isNote} from './utils/guards'; + +/** + * Adds note support to a workspace. + */ +export class WorkspaceNotes { + /** The workspace this instance is attached to. */ + protected workspace: Blockly.Workspace; + + /** The options this instance was constructed with, over the defaults. */ + protected options: Required; + + /** The workspace's own `newComment`, while ours is in its place. */ + private originalNewComment_: + ((id?: string) => Blockly.comments.WorkspaceComment) | null = null; + + /** Whether `init` has run, so it stays idempotent. */ + private initialized_ = false; + + /** + * @param workspace The workspace to add notes to. A headless workspace works + * too; it simply gets unrendered notes. + * @param options Plugin options. `skipSerializerRegistration` leaves + * persistence entirely to the host app; `emitLegacyComments` keeps + * writing the old `workspaceComments` key as well, which duplicates + * every note and is off by default; `xmlSupport` wraps the `Blockly.Xml` + * entry points so notes survive the older XML format too, and can be + * turned off by a host that only uses JSON. + */ + constructor( + workspace: Blockly.Workspace, + options: WorkspaceNotesOptions = {}, + ) { + this.workspace = workspace; + this.options = { + palette: DEFAULT_PALETTE, + defaultSize: DEFAULT_SIZE, + contextMenu: true, + skipSerializerRegistration: false, + emitLegacyComments: false, + xmlSupport: true, + getAuthor: () => '', + ...options, + }; + } + + /** + * Starts the plugin. + */ + init(): void { + if (this.initialized_) return; + this.initialized_ = true; + + // Blockly's own default is sized for a comment whose whole chrome is a + // 24px bar; a note's margins would leave that barely two lines. + const {width, height} = this.options.defaultSize; + Blockly.comments.CommentView.defaultCommentSize = new Blockly.utils.Size( + width, + height, + ); + + // Patched on the instance rather than the prototype: scoped to this + // workspace and trivially reversible. This is what makes undoing a delete + // rebuild a Note — core's CommentCreate replays through + // `workspace.newComment()`. + this.originalNewComment_ = this.workspace.newComment; + this.workspace.newComment = (id) => makeNote(this.workspace, id); + + registerNoteChangeEvent(); + + if (!this.options.skipSerializerRegistration) { + registerNoteSerializers({ + emitLegacyComments: this.options.emitLegacyComments, + }); + } + + registerNotePaster(); + + if (this.options.xmlSupport) { + registerXmlSupport(); + } + + if (this.options.contextMenu) { + registerNoteContextMenu({palette: this.options.palette}); + } + } + + /** + * Stops the plugin and restores everything it replaced. + * + * The serializer, paster and context menu registries are global singletons + * shared by every workspace, so they are reference-counted and only really + * restored once the last plugin instance is disposed. + */ + dispose(): void { + if (!this.initialized_) return; + this.initialized_ = false; + + if (this.originalNewComment_) { + this.workspace.newComment = this.originalNewComment_; + this.originalNewComment_ = null; + } + + if (!this.options.skipSerializerRegistration) { + unregisterNoteSerializers(); + } + + unregisterNotePaster(); + + if (this.options.xmlSupport) { + unregisterXmlSupport(); + } + + if (this.options.contextMenu) { + unregisterNoteContextMenu(); + } + } + + /** + * Creates a note on the workspace. + * + * @param state Initial values for the note. + * @returns The new note. + */ + createNote(state: Partial = {}): Note | NoteComment { + const existingGroup = Blockly.Events.getGroup(); + if (!existingGroup) Blockly.Events.setGroup(true); + try { + const note = makeNote(this.workspace); + if (state.text) note.setText(state.text); + if (state.title) note.setTitle(state.title); + if (state.colour) note.setColour(state.colour); + if (state.x !== undefined || state.y !== undefined) { + note.moveTo(new Blockly.utils.Coordinate(state.x ?? 0, state.y ?? 0)); + } + note.setZIndex(nextZIndex(this.workspace)); + note.restoreMeta({author: this.options.getAuthor()}); + return note; + } finally { + Blockly.Events.setGroup(existingGroup); + } + } + + /** + * @returns Every note on the workspace. + */ + getNotes(): Array { + return this.workspace.getTopComments(false).filter(isNote); + } +} diff --git a/blockly-workspace-notes/src/serialization/legacy_comment_adapter.ts b/blockly-workspace-notes/src/serialization/legacy_comment_adapter.ts new file mode 100644 index 0000000..d3b9ac1 --- /dev/null +++ b/blockly-workspace-notes/src/serialization/legacy_comment_adapter.ts @@ -0,0 +1,80 @@ +/** + * @fileoverview A stand-in for Blockly's built-in comment serializer. + * + * A note is a `RenderedWorkspaceComment`, so it also lands in + * `workspace.getTopComments()` and core's own comment serializer would save it + * a second time — reloading would then produce two notes for every one. This + * adapter replaces that serializer: it writes nothing, and exists only so that + * files carrying the old `workspaceComments` key still load, as notes. + */ + +import * as Blockly from 'blockly/core'; + +import type {WorkspaceNotesOptions} from '../types/options'; +import type {SavedNote} from '../types/serialization'; +import {isNote} from '../utils/guards'; +import {appendNote, saveNote} from './state'; + +/** + * Stands in for Blockly's built-in comment serializer. + * + * Writes nothing — notes are the single source of truth — but still reads + * `workspaceComments`, so files saved before this plugin (or by plain Blockly) + * load as notes. + * + * @implements {Blockly.serialization.ISerializer} + */ +export class LegacyCommentAdapter implements Blockly.serialization.ISerializer { + /** + * Whether to keep writing the old workspaceComments key. Off by default, + * since it duplicates every note. + */ + private emitLegacyComments_: boolean; + + /** Runs alongside core's own comment serializer. */ + priority: number; + + /** + * @param options Whether to keep emitting legacy comments. + * @param options.emitLegacyComments Whether to write the old key too. + */ + constructor({emitLegacyComments = false}: WorkspaceNotesOptions = {}) { + this.priority = Blockly.serialization.priorities.WORKSPACE_COMMENTS; + this.emitLegacyComments_ = emitLegacyComments; + } + + /** + * @param workspace The workspace to serialize. + * @returns Legacy comment states, or null. + */ + save(workspace: Blockly.Workspace): SavedNote[] | null { + if (!this.emitLegacyComments_) return null; + const comments = workspace + .getTopComments(false) + .filter(isNote) + .map((note) => saveNote(note, {addCoordinates: true, saveIds: true})); + return comments.length ? comments : null; + } + + /** + * @param state Legacy comment states. + * @param workspace The workspace to load into. + */ + load(state: object, workspace: Blockly.Workspace): void { + const recordUndo = Blockly.Events.getRecordUndo(); + // The interface types this as `object`; core passes the array core wrote. + for (const commentState of state as SavedNote[]) { + // Notes load first (higher priority). If a file somehow carries both + // keys, the note wins rather than being duplicated under a fresh ID. + if (commentState.id && workspace.getCommentById(commentState.id)) { + continue; + } + appendNote(commentState, workspace, {recordUndo}); + } + } + + /** + * Intentionally empty; NoteSerializer.clear disposes every comment. + */ + clear(): void {} +} diff --git a/blockly-workspace-notes/src/serialization/migrations.ts b/blockly-workspace-notes/src/serialization/migrations.ts new file mode 100644 index 0000000..52345ea --- /dev/null +++ b/blockly-workspace-notes/src/serialization/migrations.ts @@ -0,0 +1,58 @@ +/** + * @fileoverview Upgrading a `workspaceNotes` payload written by an older + * version of this plugin. + * + * Every save file ever written has to keep loading, so this is the one place + * that knows what earlier versions looked like. Adding a field to `SavedNote` + * needs a `SCHEMA_VERSION` bump in `constants/serialization.ts` and an entry + * in `MIGRATIONS` below, keyed by the version it upgrades *from*. + */ + +import {SCHEMA_VERSION} from '../constants/serialization'; +import type {NotesPayload, SavedNote} from '../types/serialization'; + +/** + * Upgrades an older payload to the current schema. + * + * Keyed by the version being upgraded *from*; each entry returns a payload at + * the next version. + */ +const MIGRATIONS: Record NotesPayload> = { + // v0 is the unversioned shape: a bare array of note states, which is also + // what a legacy `workspaceComments` list looks like. + 0: (state) => ({version: 1, notes: state.notes ?? []}), +}; + +/** + * Normalizes and upgrades a `workspaceNotes` payload. + * + * @param state The raw payload. + * @returns A payload at the current schema version. + */ +export function migrate( + state: NotesPayload | SavedNote[] | undefined, +): NotesPayload { + let current: NotesPayload = Array.isArray(state) + ? {version: 0, notes: state} + : {version: Number(state?.version) || 0, notes: state?.notes ?? []}; + + if (current.version > SCHEMA_VERSION) { + console.warn( + `Loading workspaceNotes v${current.version} with a plugin that ` + + `understands v${SCHEMA_VERSION}; unknown fields will be dropped.`, + ); + return {version: SCHEMA_VERSION, notes: current.notes}; + } + + while (current.version < SCHEMA_VERSION) { + const step = MIGRATIONS[current.version]; + if (!step) break; + const next = step(current); + // Guard against a migration that fails to advance the version, which + // would otherwise spin forever. + if ((Number(next.version) || 0) <= current.version) break; + current = next; + } + + return current; +} diff --git a/blockly-workspace-notes/src/serialization/note_serializer.ts b/blockly-workspace-notes/src/serialization/note_serializer.ts new file mode 100644 index 0000000..21e6123 --- /dev/null +++ b/blockly-workspace-notes/src/serialization/note_serializer.ts @@ -0,0 +1,79 @@ +/** + * @fileoverview The serializer that owns the `workspaceNotes` key. + * + * Notes ride along in the same payload as blocks and variables: + * + * { + * "blocks": {...}, + * "workspaceNotes": {"version": 1, "notes": [...]} + * } + * + * It also owns clearing the workspace for both keys — see `clear` — which is + * why `LegacyCommentAdapter.clear` is deliberately empty. + */ + +import * as Blockly from 'blockly/core'; + +import {SCHEMA_VERSION} from '../constants/serialization'; +import {restackNotes} from '../model/stacking'; +import type {NotesPayload} from '../types/serialization'; +import {isNote} from '../utils/guards'; +import {migrate} from './migrations'; +import {appendNote, saveNote} from './state'; + +/** + * Saves and loads notes under the `workspaceNotes` key. + * + * @implements {Blockly.serialization.ISerializer} + */ +export class NoteSerializer implements Blockly.serialization.ISerializer { + /** + * Ordered just above core's comment priority so that notes load before the + * legacy adapter runs and it can skip anything already present. Equal + * priorities are ordered arbitrarily relative to each other, so the two must + * differ. + */ + priority = Blockly.serialization.priorities.WORKSPACE_COMMENTS + 1; + + /** + * @param workspace The workspace to serialize. + * @returns The notes payload, or null when there are none. + */ + save(workspace: Blockly.Workspace): NotesPayload | null { + const notes = workspace + .getTopComments(false) + .filter(isNote) + .map((note) => saveNote(note, {addCoordinates: true, saveIds: true})); + // Returning null omits the key entirely; an empty object would be falsy + // to `workspaces.load` anyway and is never worth writing. + return notes.length ? {version: SCHEMA_VERSION, notes} : null; + } + + /** + * @param state The notes payload. + * @param workspace The workspace to load into. + */ + load(state: object, workspace: Blockly.Workspace): void { + const recordUndo = Blockly.Events.getRecordUndo(); + for (const noteState of migrate(state as NotesPayload).notes) { + appendNote(noteState, workspace, {recordUndo}); + } + restackNotes(workspace); + } + + /** + * Disposes of every comment on the workspace. + * + * This owns clearing for both keys: the legacy adapter deliberately does + * nothing so that nothing is disposed twice. Non-note comments are included, + * matching what core's serializer did before we replaced it — otherwise they + * would survive a load and accumulate. + * + * @param workspace The workspace to clear. + */ + clear(workspace: Blockly.Workspace): void { + for (const comment of workspace.getTopComments(false)) { + comment.dispose(); + } + } +} diff --git a/blockly-workspace-notes/src/serialization/registry.ts b/blockly-workspace-notes/src/serialization/registry.ts new file mode 100644 index 0000000..4ea88a3 --- /dev/null +++ b/blockly-workspace-notes/src/serialization/registry.ts @@ -0,0 +1,77 @@ +/** + * @fileoverview Swapping the plugin's serializers into Blockly's registry, and + * putting core's back. + * + * The registry is a global singleton while the plugin is per-workspace, so + * registration is reference-counted: two workspaces on one page register once + * between them, and core's serializer only returns when the last one goes. + */ + +import * as Blockly from 'blockly/core'; + +import { + COMMENT_SERIALIZER_NAME, + NOTE_SERIALIZER_NAME, +} from '../constants/serialization'; +import type {WorkspaceNotesOptions} from '../types/options'; +import {LegacyCommentAdapter} from './legacy_comment_adapter'; +import {NoteSerializer} from './note_serializer'; + +/** + * Whether this module has swapped the serializers in yet. The registry is a + * global singleton but the plugin is per-workspace, so registration is + * reference-counted. + */ +let registrationCount = 0; + +/** + * The comment serializer displaced on first registration, so it can be put + * back exactly as it was. + */ +let displacedCommentSerializer: Blockly.serialization.ISerializer | null = null; + +/** + * Registers the note serializer and replaces Blockly's comment serializer. + * + * @param [options] Serializer options, + * honoured on the first registration. + */ +export function registerNoteSerializers( + options: WorkspaceNotesOptions = {}, +): void { + if (registrationCount++) return; + + displacedCommentSerializer = Blockly.registry.getObject( + Blockly.registry.Type.SERIALIZER, + COMMENT_SERIALIZER_NAME, + false, + ); + + // `register` throws on a duplicate name, so the built-in must go first. + Blockly.serialization.registry.unregister(COMMENT_SERIALIZER_NAME); + Blockly.serialization.registry.register( + COMMENT_SERIALIZER_NAME, + new LegacyCommentAdapter(options), + ); + Blockly.serialization.registry.register( + NOTE_SERIALIZER_NAME, + new NoteSerializer(), + ); +} + +/** + * Restores Blockly's built-in comment serializer. + */ +export function unregisterNoteSerializers(): void { + if (--registrationCount > 0) return; + registrationCount = 0; + + Blockly.serialization.registry.unregister(NOTE_SERIALIZER_NAME); + Blockly.serialization.registry.unregister(COMMENT_SERIALIZER_NAME); + Blockly.serialization.registry.register( + COMMENT_SERIALIZER_NAME, + displacedCommentSerializer ?? + new Blockly.serialization.workspaceComments.WorkspaceCommentSerializer(), + ); + displacedCommentSerializer = null; +} diff --git a/blockly-workspace-notes/src/serialization/state.ts b/blockly-workspace-notes/src/serialization/state.ts new file mode 100644 index 0000000..dedb193 --- /dev/null +++ b/blockly-workspace-notes/src/serialization/state.ts @@ -0,0 +1,164 @@ +/** + * @fileoverview Turning a note into plain JSON and back. + * + * These three functions are the whole of the note format. Everything else in + * `serialization/` is about *when* they run: the serializers call them for a + * whole workspace, the XML layer calls them for one note at a time, and the + * paster calls `appendNote` to rebuild a copied note. + * + * `saveNote` is sparse by design, matching core's comment serializer — width + * and height always, everything else only when it differs from the default — + * so saved files stay small and diffable. + */ + +import * as Blockly from 'blockly/core'; + +import {DEFAULT_COLOUR} from '../constants/colours'; +import {Note} from '../model/note'; +import {NoteComment} from '../model/note_comment'; +import type {SavedNote} from '../types/serialization'; + +/** + * Constructs the right note class for a workspace. A headless workspace + * cannot hold a rendered note, and that is the only kind a Node test can make. + * + * @param workspace The workspace to create the note on. + * @param [id] An optional ID. + * @returns The new note. + */ +export function createNote( + workspace: Blockly.Workspace, + id?: string, +): Note | NoteComment { + // `rendered` is what distinguishes the two at runtime; the compiler cannot + // narrow a base Workspace from a boolean flag. + return workspace.rendered + ? new Note(workspace as Blockly.WorkspaceSvg, id) + : new NoteComment(workspace, id); +} + +/** + * Serializes a single note. + * + * Sparse by design, matching core's comment serializer: width and height are + * always written, and everything else only when it differs from the default. + * That keeps saved files small and diffable. + * + * @param note The note to save. + * @param options What to include beyond the note's own fields. + * @param options.addCoordinates Whether to write the note's position. + * @param options.saveIds Whether to write the note's id. + * @returns The note's JSON state. + */ +export function saveNote( + note: Note | NoteComment, + {addCoordinates = false, saveIds = false} = {}, +): SavedNote { + const workspace = note.workspace; + const state: SavedNote = {} as SavedNote; + + state.height = note.getSize().height; + state.width = note.getSize().width; + + if (saveIds) state.id = note.id; + + if (addCoordinates) { + const loc = note.getRelativeToSurfaceXY(); + state.x = workspace.RTL ? workspace.getWidth() - loc.x : loc.x; + state.y = loc.y; + } + + if (note.getText()) state.text = note.getText(); + if (note.isCollapsed()) state.collapsed = true; + + // `isOwn*` rather than `is*`: a read-only *workspace* must not poison the + // per-note flags we persist. + if (!note.isOwnEditable()) state.editable = false; + if (!note.isOwnDeletable()) state.deletable = false; + + const pinned = typeof note.isPinned === 'function' && note.isPinned(); + // A pinned note is immovable by definition, so `movable` would be noise. + if (!note.isOwnMovable() && !pinned) state.movable = false; + + // Note-specific fields. A plain comment created outside the plugin has + // none of these accessors; it simply serializes without them. + if (typeof note.getTitle !== 'function') return state; + + if (note.getTitle()) state.title = note.getTitle(); + // Case-insensitive: `setColour` normalizes through Blockly's parser, but a + // caller could have written an uppercase hex straight into the state. + if (note.getColour().toLowerCase() !== DEFAULT_COLOUR) { + state.colour = note.getColour(); + } + if (pinned) state.pinned = true; + if (note.getZIndex()) state.zIndex = note.getZIndex(); + + const meta = note.getMeta(); + if (Object.values(meta).some((value) => value)) state.meta = meta; + + return state; +} + +/** + * Creates a note on a workspace from its JSON state. + * + * @param state The note state to load. + * @param workspace The workspace to add the note to. + * @param [options] Whether the resulting events + * should be undoable. + * @param options.recordUndo Whether the append should be undoable. + * @returns The created note. + */ +export function appendNote( + state: SavedNote, + workspace: Blockly.Workspace, + {recordUndo = false} = {}, +): Note | NoteComment { + const previousRecordUndo = Blockly.Events.getRecordUndo(); + Blockly.Events.setRecordUndo(recordUndo); + const existingGroup = Blockly.Events.getGroup(); + if (!existingGroup) Blockly.Events.setGroup(true); + + let note; + try { + note = createNote(workspace, state.id); + + if (state.text !== undefined) note.setText(state.text); + + if (state.x !== undefined || state.y !== undefined) { + const rawX = state.x ?? 0; + const x = workspace.RTL ? workspace.getWidth() - rawX : rawX; + note.moveTo(new Blockly.utils.Coordinate(x, state.y ?? 0)); + } + + if (state.width !== undefined || state.height !== undefined) { + note.setSize(new Blockly.utils.Size(state.width ?? 0, state.height ?? 0)); + } + + if (state.collapsed !== undefined) { + note.setCollapsed(state.collapsed as boolean); + } + if (state.editable !== undefined) + note.setEditable(state.editable as boolean); + if (state.movable !== undefined) note.setMovable(state.movable as boolean); + if (state.deletable !== undefined) { + note.setDeletable(state.deletable as boolean); + } + + if (typeof note.setTitle === 'function') { + if (state.colour !== undefined) note.setColour(state.colour); + if (state.title !== undefined) note.setTitle(state.title); + if (state.zIndex !== undefined) note.setZIndex(state.zIndex); + // Applied after `movable`, which it overrides. + if (state.pinned) note.setPinned(true); + // Last, and deliberately not through a setter: restoring metadata must + // not stamp a fresh `updatedAt` over the one we just loaded. + if (state.meta) note.restoreMeta(state.meta); + } + } finally { + Blockly.Events.setGroup(existingGroup); + Blockly.Events.setRecordUndo(previousRecordUndo); + } + + return note; +} diff --git a/blockly-workspace-notes/src/serialization/xml/dom.ts b/blockly-workspace-notes/src/serialization/xml/dom.ts new file mode 100644 index 0000000..c2bdca3 --- /dev/null +++ b/blockly-workspace-notes/src/serialization/xml/dom.ts @@ -0,0 +1,175 @@ +/** + * @fileoverview Reading and writing one `` element. + * + * Blockly 13 still writes and reads workspace comments as XML, and a note has + * to survive that round trip. Everything here funnels through the same state + * objects `serialization/state.ts` produces, so the XML and JSON formats + * cannot drift apart. + * + * The extra attributes are all optional, so an element written here still + * reads as an ordinary comment in plain Blockly — it simply ignores what it + * does not recognise. + */ + +import * as Blockly from 'blockly/core'; + +import type {Note} from '../../model/note'; +import type {NoteComment} from '../../model/note_comment'; +import type {SavedNote} from '../../types/serialization'; +import {appendNote, saveNote} from '../state'; + +/** + * Converts a note's JSON state into attributes on a `` element. + * + * Only the note-specific fields are written; core has already supplied + * `id`/`x`/`y`/`w`/`h` and the text content. Everything added here is + * optional, so older Blockly reads the element as an ordinary comment and + * simply ignores what it does not recognise. + * + * @param elem The `` element to decorate. + * @param state The note state, as produced by `saveNote`. + */ +export function decorateElement(elem: Element, state: SavedNote): void { + if (state.title) elem.setAttribute('title', state.title); + if (state.colour) elem.setAttribute('colour', state.colour); + if (state.pinned) elem.setAttribute('pinned', 'true'); + // `z` rather than `zIndex`, grouping it with core's terse x/y/w/h geometry. + if (state.zIndex) elem.setAttribute('z', `${state.zIndex}`); + if (state.meta?.author) elem.setAttribute('author', state.meta.author); + if (state.meta?.createdAt) elem.setAttribute('created', state.meta.createdAt); + if (state.meta?.updatedAt) elem.setAttribute('updated', state.meta.updatedAt); +} + +/** + * Reads a `` element into the state shape the JSON loader uses. + * + * Mirrors core's `loadWorkspaceComment` for the shared attributes, including + * its `isNaN` guards, so a malformed value is skipped rather than written as + * NaN. The RTL flip is left to `appendNote`, which already applies it. + * + * @param elem A `` element. + * @returns The note state. + */ +export function domToNoteState(elem: Element): SavedNote { + const state: SavedNote = {} as SavedNote; + + const id = elem.getAttribute('id'); + if (id) state.id = id; + + const x = parseInt(elem.getAttribute('x') ?? '', 10); + const y = parseInt(elem.getAttribute('y') ?? '', 10); + if (!isNaN(x) && !isNaN(y)) { + state.x = x; + state.y = y; + } + + const width = parseInt(elem.getAttribute('w') ?? '', 10); + const height = parseInt(elem.getAttribute('h') ?? '', 10); + if (!isNaN(width) && !isNaN(height)) { + state.width = width; + state.height = height; + } + + if (elem.textContent) state.text = elem.textContent; + if (elem.getAttribute('collapsed') === 'true') state.collapsed = true; + if (elem.getAttribute('editable') === 'false') state.editable = false; + if (elem.getAttribute('movable') === 'false') state.movable = false; + if (elem.getAttribute('deletable') === 'false') state.deletable = false; + + const title = elem.getAttribute('title'); + if (title) state.title = title; + + const colour = elem.getAttribute('colour'); + if (colour) state.colour = colour; + + if (elem.getAttribute('pinned') === 'true') state.pinned = true; + + const zIndex = parseInt(elem.getAttribute('z') ?? '', 10); + if (!isNaN(zIndex)) state.zIndex = zIndex; + + const meta = { + author: elem.getAttribute('author') ?? '', + createdAt: elem.getAttribute('created') ?? '', + updatedAt: elem.getAttribute('updated') ?? '', + }; + if (Object.values(meta).some((value) => value)) state.meta = meta; + + return state; +} + +/** + * Serializes a note to a `` element. + * + * @param note The note to save. + * @param [skipId] True to omit the note's ID. + * @returns The `` element. + */ +export function noteToDom(note: Note | NoteComment, skipId = false): Element { + const elem = Blockly.utils.xml.createElement('comment'); + const state = saveNote(note, {addCoordinates: true, saveIds: !skipId}); + + if (state.id) elem.setAttribute('id', state.id); + elem.setAttribute('x', `${state.x}`); + elem.setAttribute('y', `${state.y}`); + elem.setAttribute('w', `${state.width}`); + elem.setAttribute('h', `${state.height}`); + + if (state.text) elem.textContent = state.text; + if (state.collapsed) elem.setAttribute('collapsed', 'true'); + if (state.editable === false) elem.setAttribute('editable', 'false'); + if (state.movable === false) elem.setAttribute('movable', 'false'); + if (state.deletable === false) elem.setAttribute('deletable', 'false'); + + decorateElement(elem, state); + return elem; +} + +/** + * Loads a `` element as a note. + * + * @param elem A `` element. + * @param workspace The workspace to load into. + * @returns The created note. + */ +export function domToNote( + elem: Element, + workspace: Blockly.Workspace, +): Note | NoteComment { + return appendNote(domToNoteState(elem), workspace, { + // Core's XML loader does not force recordUndo the way the JSON loader + // does, so honour whatever the caller has set. + recordUndo: Blockly.Events.getRecordUndo(), + }); +} + +/** + * @param xml An `` element. + * @returns Its direct `` children. Block comments + * are nested inside `` and so are untouched. + */ +export function topLevelComments(xml: Element): Element[] { + return Array.from(xml.childNodes).filter( + (node): node is Element => node.nodeName.toLowerCase() === 'comment', + ); +} + +/** + * Returns a copy of an `` element with its top-level comments removed, + * alongside the removed elements. + * + * Loading is delegated to Blockly for everything except comments, and the + * notes are created afterwards. The caller's DOM is cloned rather than + * mutated, since callers commonly reuse the same document. + * + * @param xml An `` element. + * @returns The split. + */ +export function splitComments(xml: Element): { + stripped: Element; + comments: Element[]; +} { + const stripped = xml.cloneNode(true) as Element; + const comments = topLevelComments(stripped); + for (const elem of comments) stripped.removeChild(elem); + return {stripped, comments}; +} diff --git a/blockly-workspace-notes/src/serialization/xml/patch.ts b/blockly-workspace-notes/src/serialization/xml/patch.ts new file mode 100644 index 0000000..fdb99cb --- /dev/null +++ b/blockly-workspace-notes/src/serialization/xml/patch.ts @@ -0,0 +1,174 @@ +/** + * @fileoverview Wrapping the `Blockly.Xml` entry points so notes survive an + * XML round trip. + * + * Without this a note saved through `Blockly.Xml.workspaceToDom` would + * silently lose its title, colour, pinned state and metadata, and would load + * back as a plain comment — `Xml.loadWorkspaceComment` hardcodes + * `new RenderedWorkspaceComment(...)` instead of going through + * `workspace.newComment()`, so the plugin's usual hook does not reach it. + * + * There is no registry for XML the way there is for JSON serializers, so the + * exports on the `Blockly.Xml` namespace are wrapped instead. Each has to be + * wrapped individually: Blockly's own `appendDomToWorkspace` and + * `clearWorkspaceAndLoadFromXml` call `domToWorkspace` through a module-local + * binding, so patching that one export does not reach them. + * + * Like the other registries this touches, `Blockly.Xml` is global while the + * plugin is per-workspace, so the wrappers are reference-counted. + */ + +import * as Blockly from 'blockly/core'; + +import {isNote} from '../../utils/guards'; +import {asOneUndoStep} from '../../utils/undo'; +import {saveNote} from '../state'; +import { + decorateElement, + domToNote, + noteToDom, + splitComments, + topLevelComments, +} from './dom'; + +/** The entry points wrapped on the Blockly.Xml namespace. */ +const PATCHED = [ + 'workspaceToDom', + 'domToWorkspace', + 'appendDomToWorkspace', + 'clearWorkspaceAndLoadFromXml', + 'saveWorkspaceComment', + 'loadWorkspaceComment', +] as const; + +/** One of the six names above. */ +type PatchedName = (typeof PATCHED)[number]; + +/** The six entry points, as a writable record. */ +type PatchedXml = {-readonly [K in PatchedName]: (typeof Blockly.Xml)[K]}; + +/** + * `Blockly.Xml` is an ES module namespace object, so TypeScript treats its + * members as read-only. Replacing them is exactly what this module does — + * there is no registry for XML the way there is for JSON serializers — so the + * namespace is viewed through one mutable alias, declared once, here. Each + * assignment below is still checked against Blockly's real signature. + */ +const Xml = Blockly.Xml as typeof Blockly.Xml & PatchedXml; + +/** The original Blockly.Xml functions, kept so they can be restored. */ +let originals: PatchedXml | null = null; + +/** + * Reference count, since Blockly.Xml is global but the plugin is per + * workspace: the wrappers stay in place until the last instance goes away. + */ +let registrationCount = 0; + +/** + * Wraps the Blockly.Xml entry points so notes survive an XML round trip. + */ +export function registerXmlSupport(): void { + if (registrationCount++) return; + + originals = { + workspaceToDom: Xml.workspaceToDom, + domToWorkspace: Xml.domToWorkspace, + appendDomToWorkspace: Xml.appendDomToWorkspace, + clearWorkspaceAndLoadFromXml: Xml.clearWorkspaceAndLoadFromXml, + saveWorkspaceComment: Xml.saveWorkspaceComment, + loadWorkspaceComment: Xml.loadWorkspaceComment, + }; + // Captured non-null: the wrappers below only exist while this is populated, + // which the module-level `let` cannot express. + const saved = originals; + + /** + * Runs a load through Blockly with comments held back, then adds the notes. + * Wrapped in one event group so a whole XML load is a single undo step; + * Blockly's own loader reuses an open group rather than starting its own. + * + * @param load The original Blockly loader to delegate to. + * @param xml The XML being loaded. + * @param workspace The target workspace. + * @returns The new block IDs, from Blockly. + */ + const loadWithNotes = ( + load: (xml: Element, workspace: W) => string[], + xml: Element, + workspace: W, + ): string[] => { + const {stripped, comments} = splitComments(xml); + return asOneUndoStep(() => { + const blockIds = load.call(Xml, stripped, workspace); + for (const elem of comments) domToNote(elem, workspace); + return blockIds; + }); + }; + + Xml.workspaceToDom = function (workspace, skipId = false) { + const dom = saved.workspaceToDom.call(Xml, workspace, skipId); + // Blockly emits one per top comment, in this same order, so the + // two line up by index. Only extra attributes are added, leaving Blockly's + // own output — including its RTL handling — exactly as it was. + const notes = workspace.getTopComments(); + topLevelComments(dom).forEach((elem, i) => { + const note = notes[i]; + if (isNote(note)) { + decorateElement(elem, saveNote(note, {addCoordinates: true})); + } + }); + return dom; + }; + + Xml.domToWorkspace = function (xml, workspace) { + return loadWithNotes(saved.domToWorkspace, xml, workspace); + }; + + // Blockly's own versions of these two reach domToWorkspace through a + // module-local binding, so the patch above never runs for them. Delegating + // the stripped XML to the originals keeps their extra behaviour — the + // block-offset maths in append, the clear in the other — intact. + Xml.appendDomToWorkspace = function (xml, workspace) { + return loadWithNotes(saved.appendDomToWorkspace, xml, workspace); + }; + + Xml.clearWorkspaceAndLoadFromXml = function (xml, workspace) { + return loadWithNotes(saved.clearWorkspaceAndLoadFromXml, xml, workspace); + }; + + // Direct callers of the per-comment helpers get note support too. + Xml.saveWorkspaceComment = function (comment, skipId = false) { + return isNote(comment) + ? noteToDom(comment, skipId) + : saved.saveWorkspaceComment.call(Xml, comment, skipId); + }; + + Xml.loadWorkspaceComment = function (elem, workspace) { + return domToNote(elem, workspace); + }; +} + +/** + * Puts one saved function back. + * + * Generic so the assignment is `PatchedXml[K] = PatchedXml[K]` for a single + * `K`, which TypeScript accepts; indexing with the whole union would not + * correlate the two sides. + * + * @param name Which entry point to restore. + * @param saved The saved originals. + */ +function restoreOne(name: K, saved: PatchedXml): void { + const target: PatchedXml = Xml; + target[name] = saved[name]; +} + +/** Restores Blockly's own XML functions. */ +export function unregisterXmlSupport(): void { + if (--registrationCount > 0) return; + registrationCount = 0; + if (!originals) return; + for (const name of PATCHED) restoreOne(name, originals); + originals = null; +} diff --git a/blockly-workspace-notes/src/serializer.ts b/blockly-workspace-notes/src/serializer.ts deleted file mode 100644 index 03ae3a6..0000000 --- a/blockly-workspace-notes/src/serializer.ts +++ /dev/null @@ -1,407 +0,0 @@ -/** - * @fileoverview JSON serialization for workspace notes. - * - * Notes ride along in the same payload as blocks and variables, under their - * own top-level `workspaceNotes` key: - * - * { - * "blocks": {...}, - * "workspaceNotes": {"version": 1, "notes": [...]} - * } - * - * A note is a `RenderedWorkspaceComment`, so it also lands in - * `workspace.getTopComments()` and Blockly's built-in comment serializer would - * save it a second time — reloading would then produce two notes for every - * one. The plugin therefore replaces that serializer with an adapter that - * writes nothing and only exists to keep older `workspaceComments` files - * loadable. - */ - -import * as Blockly from 'blockly/core'; - -import { - COMMENT_SERIALIZER_NAME, - DEFAULT_COLOUR, - NOTE_SERIALIZER_NAME, - SCHEMA_VERSION, -} from './constants'; -import type {NotesPayload, SavedNote, WorkspaceNotesOptions} from './types'; -import {Note, NoteComment, isNote, restackNotes} from './note'; - -/** - * Constructs the right note class for a workspace. A headless workspace - * cannot hold a rendered note, and that is the only kind a Node test can make. - * - * @param workspace The workspace to create the note on. - * @param [id] An optional ID. - * @returns The new note. - */ -export function createNote( - workspace: Blockly.Workspace, - id?: string, -): Note | NoteComment { - // `rendered` is what distinguishes the two at runtime; the compiler cannot - // narrow a base Workspace from a boolean flag. - return workspace.rendered - ? new Note(workspace as Blockly.WorkspaceSvg, id) - : new NoteComment(workspace, id); -} - -/** - * Serializes a single note. - * - * Sparse by design, matching core's comment serializer: width and height are - * always written, and everything else only when it differs from the default. - * That keeps saved files small and diffable. - * - * @param note The note to save. - * @param options What to include beyond the note's own fields. - * @param options.addCoordinates Whether to write the note's position. - * @param options.saveIds Whether to write the note's id. - * @returns The note's JSON state. - */ -export function saveNote( - note: Note | NoteComment, - {addCoordinates = false, saveIds = false} = {}, -): SavedNote { - const workspace = note.workspace; - const state: SavedNote = {} as SavedNote; - - state.height = note.getSize().height; - state.width = note.getSize().width; - - if (saveIds) state.id = note.id; - - if (addCoordinates) { - const loc = note.getRelativeToSurfaceXY(); - state.x = workspace.RTL ? workspace.getWidth() - loc.x : loc.x; - state.y = loc.y; - } - - if (note.getText()) state.text = note.getText(); - if (note.isCollapsed()) state.collapsed = true; - - // `isOwn*` rather than `is*`: a read-only *workspace* must not poison the - // per-note flags we persist. - if (!note.isOwnEditable()) state.editable = false; - if (!note.isOwnDeletable()) state.deletable = false; - - const pinned = typeof note.isPinned === 'function' && note.isPinned(); - // A pinned note is immovable by definition, so `movable` would be noise. - if (!note.isOwnMovable() && !pinned) state.movable = false; - - // Note-specific fields. A plain comment created outside the plugin has - // none of these accessors; it simply serializes without them. - if (typeof note.getTitle !== 'function') return state; - - if (note.getTitle()) state.title = note.getTitle(); - // Case-insensitive: `setColour` normalizes through Blockly's parser, but a - // caller could have written an uppercase hex straight into the state. - if (note.getColour().toLowerCase() !== DEFAULT_COLOUR) { - state.colour = note.getColour(); - } - if (pinned) state.pinned = true; - if (note.getZIndex()) state.zIndex = note.getZIndex(); - - const meta = note.getMeta(); - if (Object.values(meta).some((value) => value)) state.meta = meta; - - return state; -} - -/** - * Creates a note on a workspace from its JSON state. - * - * @param state The note state to load. - * @param workspace The workspace to add the note to. - * @param [options] Whether the resulting events - * should be undoable. - * @param options.recordUndo Whether the append should be undoable. - * @returns The created note. - */ -export function appendNote( - state: SavedNote, - workspace: Blockly.Workspace, - {recordUndo = false} = {}, -): Note | NoteComment { - const previousRecordUndo = Blockly.Events.getRecordUndo(); - Blockly.Events.setRecordUndo(recordUndo); - const existingGroup = Blockly.Events.getGroup(); - if (!existingGroup) Blockly.Events.setGroup(true); - - let note; - try { - note = createNote(workspace, state.id); - - if (state.text !== undefined) note.setText(state.text); - - if (state.x !== undefined || state.y !== undefined) { - const rawX = state.x ?? 0; - const x = workspace.RTL ? workspace.getWidth() - rawX : rawX; - note.moveTo(new Blockly.utils.Coordinate(x, state.y ?? 0)); - } - - if (state.width !== undefined || state.height !== undefined) { - note.setSize(new Blockly.utils.Size(state.width ?? 0, state.height ?? 0)); - } - - if (state.collapsed !== undefined) { - note.setCollapsed(state.collapsed as boolean); - } - if (state.editable !== undefined) - note.setEditable(state.editable as boolean); - if (state.movable !== undefined) note.setMovable(state.movable as boolean); - if (state.deletable !== undefined) { - note.setDeletable(state.deletable as boolean); - } - - if (typeof note.setTitle === 'function') { - if (state.colour !== undefined) note.setColour(state.colour); - if (state.title !== undefined) note.setTitle(state.title); - if (state.zIndex !== undefined) note.setZIndex(state.zIndex); - // Applied after `movable`, which it overrides. - if (state.pinned) note.setPinned(true); - // Last, and deliberately not through a setter: restoring metadata must - // not stamp a fresh `updatedAt` over the one we just loaded. - if (state.meta) note.restoreMeta(state.meta); - } - } finally { - Blockly.Events.setGroup(existingGroup); - Blockly.Events.setRecordUndo(previousRecordUndo); - } - - return note; -} - -/** - * Upgrades an older payload to the current schema. - * - * Keyed by the version being upgraded *from*; each entry returns a payload at - * the next version. - */ -const MIGRATIONS: Record NotesPayload> = { - // v0 is the unversioned shape: a bare array of note states, which is also - // what a legacy `workspaceComments` list looks like. - 0: (state) => ({version: 1, notes: state.notes ?? []}), -}; - -/** - * Normalizes and upgrades a `workspaceNotes` payload. - * - * @param state The raw payload. - * @returns A payload at the current schema version. - */ -export function migrate( - state: NotesPayload | SavedNote[] | undefined, -): NotesPayload { - let current: NotesPayload = Array.isArray(state) - ? {version: 0, notes: state} - : {version: Number(state?.version) || 0, notes: state?.notes ?? []}; - - if (current.version > SCHEMA_VERSION) { - console.warn( - `Loading workspaceNotes v${current.version} with a plugin that ` + - `understands v${SCHEMA_VERSION}; unknown fields will be dropped.`, - ); - return {version: SCHEMA_VERSION, notes: current.notes}; - } - - while (current.version < SCHEMA_VERSION) { - const step = MIGRATIONS[current.version]; - if (!step) break; - const next = step(current); - // Guard against a migration that fails to advance the version, which - // would otherwise spin forever. - if ((Number(next.version) || 0) <= current.version) break; - current = next; - } - - return current; -} - -/** - * Saves and loads notes under the `workspaceNotes` key. - * - * @implements {Blockly.serialization.ISerializer} - */ -export class NoteSerializer implements Blockly.serialization.ISerializer { - /** - * Ordered just above core's comment priority so that notes load before the - * legacy adapter runs and it can skip anything already present. Equal - * priorities are ordered arbitrarily relative to each other, so the two must - * differ. - */ - priority = Blockly.serialization.priorities.WORKSPACE_COMMENTS + 1; - - /** - * @param workspace The workspace to serialize. - * @returns The notes payload, or null when there are none. - */ - save(workspace: Blockly.Workspace): NotesPayload | null { - const notes = workspace - .getTopComments(false) - .filter(isNote) - .map((note) => saveNote(note, {addCoordinates: true, saveIds: true})); - // Returning null omits the key entirely; an empty object would be falsy - // to `workspaces.load` anyway and is never worth writing. - return notes.length ? {version: SCHEMA_VERSION, notes} : null; - } - - /** - * @param state The notes payload. - * @param workspace The workspace to load into. - */ - load(state: object, workspace: Blockly.Workspace): void { - const recordUndo = Blockly.Events.getRecordUndo(); - for (const noteState of migrate(state as NotesPayload).notes) { - appendNote(noteState, workspace, {recordUndo}); - } - restackNotes(workspace); - } - - /** - * Disposes of every comment on the workspace. - * - * This owns clearing for both keys: the legacy adapter deliberately does - * nothing so that nothing is disposed twice. Non-note comments are included, - * matching what core's serializer did before we replaced it — otherwise they - * would survive a load and accumulate. - * - * @param workspace The workspace to clear. - */ - clear(workspace: Blockly.Workspace): void { - for (const comment of workspace.getTopComments(false)) { - comment.dispose(); - } - } -} - -/** - * Stands in for Blockly's built-in comment serializer. - * - * Writes nothing — notes are the single source of truth — but still reads - * `workspaceComments`, so files saved before this plugin (or by plain Blockly) - * load as notes. - * - * @implements {Blockly.serialization.ISerializer} - */ -export class LegacyCommentAdapter implements Blockly.serialization.ISerializer { - /** - * @param [options] Set - * `emitLegacyComments` to keep writing the old key for a host that still - * reads it. Off by default, since it duplicates every note. - */ - /** Whether to keep writing the old workspaceComments key. */ - private emitLegacyComments_: boolean; - - /** Runs alongside core's own comment serializer. */ - priority: number; - - /** - * @param options Whether to keep emitting legacy comments. - * @param options.emitLegacyComments Whether to write the old key too. - */ - constructor({emitLegacyComments = false}: WorkspaceNotesOptions = {}) { - /** @type {number} */ - this.priority = Blockly.serialization.priorities.WORKSPACE_COMMENTS; - - /** - * @private - */ - this.emitLegacyComments_ = emitLegacyComments; - } - - /** - * @param workspace The workspace to serialize. - * @returns Legacy comment states, or null. - */ - save(workspace: Blockly.Workspace): SavedNote[] | null { - if (!this.emitLegacyComments_) return null; - const comments = workspace - .getTopComments(false) - .filter(isNote) - .map((note) => saveNote(note, {addCoordinates: true, saveIds: true})); - return comments.length ? comments : null; - } - - /** - * @param state Legacy comment states. - * @param workspace The workspace to load into. - */ - load(state: object, workspace: Blockly.Workspace): void { - const recordUndo = Blockly.Events.getRecordUndo(); - // The interface types this as `object`; core passes the array core wrote. - for (const commentState of state as SavedNote[]) { - // Notes load first (higher priority). If a file somehow carries both - // keys, the note wins rather than being duplicated under a fresh ID. - if (commentState.id && workspace.getCommentById(commentState.id)) { - continue; - } - appendNote(commentState, workspace, {recordUndo}); - } - } - - /** - * Intentionally empty; NoteSerializer.clear disposes every comment. - */ - clear(): void {} -} - -/** - * Whether this module has swapped the serializers in yet. The registry is a - * global singleton but the plugin is per-workspace, so registration is - * reference-counted. - */ -let registrationCount = 0; - -/** - * The comment serializer displaced on first registration, so it can be put - * back exactly as it was. - */ -let displacedCommentSerializer: Blockly.serialization.ISerializer | null = null; - -/** - * Registers the note serializer and replaces Blockly's comment serializer. - * - * @param [options] Serializer options, - * honoured on the first registration. - */ -export function registerNoteSerializers( - options: WorkspaceNotesOptions = {}, -): void { - if (registrationCount++) return; - - displacedCommentSerializer = Blockly.registry.getObject( - Blockly.registry.Type.SERIALIZER, - COMMENT_SERIALIZER_NAME, - false, - ); - - // `register` throws on a duplicate name, so the built-in must go first. - Blockly.serialization.registry.unregister(COMMENT_SERIALIZER_NAME); - Blockly.serialization.registry.register( - COMMENT_SERIALIZER_NAME, - new LegacyCommentAdapter(options), - ); - Blockly.serialization.registry.register( - NOTE_SERIALIZER_NAME, - new NoteSerializer(), - ); -} - -/** - * Restores Blockly's built-in comment serializer. - */ -export function unregisterNoteSerializers(): void { - if (--registrationCount > 0) return; - registrationCount = 0; - - Blockly.serialization.registry.unregister(NOTE_SERIALIZER_NAME); - Blockly.serialization.registry.unregister(COMMENT_SERIALIZER_NAME); - Blockly.serialization.registry.register( - COMMENT_SERIALIZER_NAME, - displacedCommentSerializer ?? - new Blockly.serialization.workspaceComments.WorkspaceCommentSerializer(), - ); - displacedCommentSerializer = null; -} diff --git a/blockly-workspace-notes/src/types.ts b/blockly-workspace-notes/src/types.ts deleted file mode 100644 index 6a04522..0000000 --- a/blockly-workspace-notes/src/types.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * @fileoverview The shapes that travel between modules. - * - * In the JavaScript version most of these were `{!Object}` — one annotation - * standing in for a note, a note's saved state, a JSON payload, clipboard data - * and an options bag. The state record is the one worth naming most: it is a - * persisted format, written to save files and read back by `migrate`, so it - * outlives any single release and belongs in one place rather than being - * re-derived wherever it is touched. - */ - -import type * as Blockly from 'blockly/core'; - -/** - * Who made a note and when. - * - * The timestamps are ISO 8601 strings rather than `Date` objects because they - * are written to JSON and read back verbatim. - */ -export interface NoteMeta { - author: string; - createdAt: string; - updatedAt: string; -} - -/** - * Everything a note carries beyond what a workspace comment already has. - */ -export interface NoteState { - title: string; - colour: string; - pinned: boolean; - zIndex: number; - meta: NoteMeta; -} - -/** - * One entry in a note palette. - * - * `hue` is what the picker sorts and derives from; `fill` is the resolved hex - * so the swatch does not have to recompute it. - */ -export interface PaletteEntry { - name: string; - hue: number; - fill: string; -} - -/** - * A note's serialized form, as it appears in a save file. - * - * Deliberately sparse: `saveNote` writes width and height always and everything - * else only when it differs from the default, so most notes serialize to a - * handful of keys. - */ -export interface SavedNote { - width: number; - height: number; - x?: number; - y?: number; - id?: string; - text?: string; - title?: string; - colour?: string; - pinned?: boolean; - zIndex?: number; - meta?: Partial; - [key: string]: unknown; -} - -/** - * The plugin's own slice of a save file, under the `workspaceNotes` key. - */ -export interface NotesPayload { - version: number; - notes: SavedNote[]; -} - -/** - * Which properties `applyNoteProperty` understands. - * - * `'*'` means "replace the whole state", which is how a paste and an undo - * restore a note in one step. - */ -export type NoteProperty = - 'title' | 'colour' | 'pinned' | 'zIndex' | 'meta' | '*'; - -/** - * The value that goes with each `NoteProperty`. - * - * This is what the `{*}` annotation stood in for: the switch in - * `applyNoteProperty` writes into a string slot, a boolean slot, a number slot, - * an object slot and a whole-state slot, and only the property name says which. - */ -export type NotePropertyValue = - string | boolean | number | Partial | NoteState | null; - -/** - * Everything `WorkspaceNotes` accepts. - */ -export interface WorkspaceNotesOptions { - palette?: PaletteEntry[]; - defaultSize?: {width: number; height: number}; - getAuthor?: () => string; - contextMenu?: boolean; - skipSerializerRegistration?: boolean; - emitLegacyComments?: boolean; - xmlSupport?: boolean; -} - -/** - * Clipboard data for a note. - * - * Core's `WorkspaceCommentCopyData` carries only `commentState`; a note adds - * its own half so a pasted note keeps its title and colour. - */ -export interface NoteCopyData { - paster: string; - commentState: {[key: string]: unknown}; - noteState?: NoteState; -} - -/** - * The JSON shape of a `NoteChange` event. - * - * Core's `CommentBaseJson` declares only `commentId`, so the three fields this - * event adds need declaring for `toJson` to be assignable to the base. - */ -export interface NoteChangeJson extends Blockly.Events.CommentBaseJson { - property?: NoteProperty; - oldValue?: NotePropertyValue; - newValue?: NotePropertyValue; -} diff --git a/blockly-workspace-notes/src/types/clipboard.ts b/blockly-workspace-notes/src/types/clipboard.ts new file mode 100644 index 0000000..19b4146 --- /dev/null +++ b/blockly-workspace-notes/src/types/clipboard.ts @@ -0,0 +1,17 @@ +/** + * @fileoverview What a copied note puts on the clipboard. + */ + +import type {NoteState} from './note'; + +/** + * Clipboard data for a note. + * + * Core's `WorkspaceCommentCopyData` carries only `commentState`; a note adds + * its own half so a pasted note keeps its title and colour. + */ +export interface NoteCopyData { + paster: string; + commentState: {[key: string]: unknown}; + noteState?: NoteState; +} diff --git a/blockly-workspace-notes/src/types/events.ts b/blockly-workspace-notes/src/types/events.ts new file mode 100644 index 0000000..4a5ca18 --- /dev/null +++ b/blockly-workspace-notes/src/types/events.ts @@ -0,0 +1,19 @@ +/** + * @fileoverview The JSON shape of the plugin's own undo event. + */ + +import type * as Blockly from 'blockly/core'; + +import type {NoteProperty, NotePropertyValue} from './note'; + +/** + * The JSON shape of a `NoteChange` event. + * + * Core's `CommentBaseJson` declares only `commentId`, so the three fields this + * event adds need declaring for `toJson` to be assignable to the base. + */ +export interface NoteChangeJson extends Blockly.Events.CommentBaseJson { + property?: NoteProperty; + oldValue?: NotePropertyValue; + newValue?: NotePropertyValue; +} diff --git a/blockly-workspace-notes/src/types/note.ts b/blockly-workspace-notes/src/types/note.ts new file mode 100644 index 0000000..1415c1d --- /dev/null +++ b/blockly-workspace-notes/src/types/note.ts @@ -0,0 +1,79 @@ +/** + * @fileoverview What a note carries beyond a workspace comment, and the + * surface the mixin adds to expose it. + * + * `NoteState` is the centre of this file and of the plugin: it is the record + * that is written to save files, restored by `migrate`, snapshotted onto undo + * events and copied to the clipboard. It outlives any single release, so it is + * named once here rather than re-derived wherever it is touched. + */ + +/** + * Who made a note and when. + * + * The timestamps are ISO 8601 strings rather than `Date` objects because they + * are written to JSON and read back verbatim. + */ +export interface NoteMeta { + author: string; + createdAt: string; + updatedAt: string; +} + +/** + * Everything a note carries beyond what a workspace comment already has. + */ +export interface NoteState { + title: string; + colour: string; + pinned: boolean; + zIndex: number; + meta: NoteMeta; +} + +/** + * Which properties `applyNoteProperty` understands. + * + * `'*'` means "replace the whole state", which is how a paste and an undo + * restore a note in one step. + */ +export type NoteProperty = + 'title' | 'colour' | 'pinned' | 'zIndex' | 'meta' | '*'; + +/** + * The value that goes with each `NoteProperty`. + * + * This is what the `{*}` annotation stood in for: the switch in + * `applyNoteProperty` writes into a string slot, a boolean slot, a number slot, + * an object slot and a whole-state slot, and only the property name says which. + */ +export type NotePropertyValue = + string | boolean | number | Partial | NoteState | null; + +/** + * Everything the note mixin adds to a workspace comment. + * + * Declared separately from the mixin because TypeScript cannot name an + * anonymous class expression in a `.d.ts`. Without this interface, `declaration: + * true` fails on `NoteComment` and `Note` with "has or is using private name". + */ +export interface NoteSurface { + getNoteState(): NoteState; + getTitle(): string; + setTitle(title: string): void; + getColour(): string; + setColour(colour: string): void; + isPinned(): boolean; + setPinned(pinned: boolean): void; + getZIndex(): number; + setZIndex(zIndex: number): void; + getMeta(): NoteMeta; + restoreMeta(meta: Partial): void; + touchMeta(): void; + saveNoteState(): NoteState; + applyNoteProperty(property: NoteProperty, value?: NotePropertyValue): void; + renderTitle(): void; + renderColour(): void; + applyPinned(): void; + applyZIndex(): void; +} diff --git a/blockly-workspace-notes/src/types/options.ts b/blockly-workspace-notes/src/types/options.ts new file mode 100644 index 0000000..faea2d4 --- /dev/null +++ b/blockly-workspace-notes/src/types/options.ts @@ -0,0 +1,29 @@ +/** + * @fileoverview What a host application passes in: the plugin's options bag, + * and the palette entries it may supply. + */ + +/** + * One entry in a note palette. + * + * `hue` is what the picker sorts and derives from; `fill` is the resolved hex + * so the swatch does not have to recompute it. + */ +export interface PaletteEntry { + name: string; + hue: number; + fill: string; +} + +/** + * Everything `WorkspaceNotes` accepts. + */ +export interface WorkspaceNotesOptions { + palette?: PaletteEntry[]; + defaultSize?: {width: number; height: number}; + getAuthor?: () => string; + contextMenu?: boolean; + skipSerializerRegistration?: boolean; + emitLegacyComments?: boolean; + xmlSupport?: boolean; +} diff --git a/blockly-workspace-notes/src/types/serialization.ts b/blockly-workspace-notes/src/types/serialization.ts new file mode 100644 index 0000000..400c1a7 --- /dev/null +++ b/blockly-workspace-notes/src/types/serialization.ts @@ -0,0 +1,40 @@ +/** + * @fileoverview The on-disk shapes: one note, and the plugin's slice of a save + * file. + * + * These are a persisted format. A change here is a change to files already + * written by earlier versions, so it needs a `SCHEMA_VERSION` bump and a + * matching entry in `serialization/migrations.ts`. + */ + +import type {NoteMeta} from './note'; + +/** + * A note's serialized form, as it appears in a save file. + * + * Deliberately sparse: `saveNote` writes width and height always and everything + * else only when it differs from the default, so most notes serialize to a + * handful of keys. + */ +export interface SavedNote { + width: number; + height: number; + x?: number; + y?: number; + id?: string; + text?: string; + title?: string; + colour?: string; + pinned?: boolean; + zIndex?: number; + meta?: Partial; + [key: string]: unknown; +} + +/** + * The plugin's own slice of a save file, under the `workspaceNotes` key. + */ +export interface NotesPayload { + version: number; + notes: SavedNote[]; +} diff --git a/blockly-workspace-notes/src/ui/colour_swatches.ts b/blockly-workspace-notes/src/ui/colour_swatches.ts new file mode 100644 index 0000000..c0d0192 --- /dev/null +++ b/blockly-workspace-notes/src/ui/colour_swatches.ts @@ -0,0 +1,88 @@ +/** + * @fileoverview The row of colour swatches the "Colour" menu item shows, and + * the styles for it. + * + * Blockly's context menu has no notion of submenus, but `displayText` accepts + * an HTMLElement — so the whole palette fits in one row rather than spilling + * seven entries into the menu. + * + * The stylesheet is registered here rather than in `ui/css.ts` because these + * rules style HTML inside the menu's widget div, not the SVG chrome of a note; + * keeping them beside the element they style is what stops one drifting from + * the other. + */ + +import * as Blockly from 'blockly/core'; + +import type {Note} from '../model/note'; +import type {PaletteEntry} from '../types/options'; +import {asOneUndoStep} from '../utils/undo'; + +/** + * Builds the row of colour swatches used as a menu item's display text. + * + * Blockly's context menu has no notion of submenus, but `displayText` accepts + * an HTMLElement — so the whole palette fits in one row rather than spilling + * seven entries into the menu. + * + * @param note The note to recolour. + * @param palette The + * swatches. + * @returns The swatch row. + */ +export function createSwatchRow( + note: Note, + palette: PaletteEntry[], +): HTMLElement { + const row = document.createElement('div'); + row.className = 'blocklyNoteSwatchRow'; + + for (const {name, fill} of palette) { + const swatch = document.createElement('button'); + swatch.type = 'button'; + swatch.className = 'blocklyNoteSwatch'; + // The swatch shows the note's colour itself, the way a block's colour + // reads in the toolbox. + swatch.style.backgroundColor = fill; + swatch.title = name; + swatch.setAttribute('aria-label', name); + if (note.getColour().toLowerCase() === fill.toLowerCase()) { + swatch.classList.add('blocklyNoteSwatchSelected'); + } + + swatch.addEventListener('pointerdown', (e) => { + // Stop the menu's own handler from also firing for this row. + e.stopPropagation(); + e.preventDefault(); + asOneUndoStep(() => note.setColour(fill)); + Blockly.ContextMenu.hide(); + }); + + row.appendChild(swatch); + } + + return row; +} + +Blockly.Css.register(` +.blocklyNoteSwatchRow { + display: flex; + gap: 6px; + padding: 2px 0; +} + +.blocklyNoteSwatch { + width: 18px; + height: 18px; + padding: 0; + border: 1px solid rgba(0, 0, 0, 0.25); + border-radius: 4px; + cursor: pointer; +} + +/* #fc3 is the selection colour core uses for comments and blocks. */ +.blocklyNoteSwatchSelected { + outline: 2px solid #fc3; + outline-offset: 1px; +} +`); diff --git a/blockly-workspace-notes/src/context_menu.ts b/blockly-workspace-notes/src/ui/context_menu.ts similarity index 74% rename from blockly-workspace-notes/src/context_menu.ts rename to blockly-workspace-notes/src/ui/context_menu.ts index 4b811a0..571c575 100644 --- a/blockly-workspace-notes/src/context_menu.ts +++ b/blockly-workspace-notes/src/ui/context_menu.ts @@ -15,9 +15,12 @@ import * as Blockly from 'blockly/core'; -import {DEFAULT_PALETTE} from './constants'; -import type {PaletteEntry} from './types'; -import {Note, nextZIndex, previousZIndex} from './note'; +import {DEFAULT_PALETTE} from '../constants/colours'; +import {Note} from '../model/note'; +import {nextZIndex, previousZIndex} from '../model/stacking'; +import {msg} from '../utils/messages'; +import {asOneUndoStep} from '../utils/undo'; +import {createSwatchRow} from './colour_swatches'; const ScopeType = Blockly.ContextMenuRegistry.ScopeType; @@ -30,18 +33,6 @@ const NOTE_ITEM_IDS = [ 'noteSendToBack', ]; -/** - * Reads a Blockly message with a fallback, since `blockly/core` on its own - * ships no message table. - * - * @param key The message key. - * @param fallback The text to use when the key is unset. - * @returns The message. - */ -function msg(key: string, fallback: string): string { - return Blockly.Msg[key] || fallback; -} - /** * @param scope The menu scope. * @returns The note the menu was opened on, if any. @@ -51,64 +42,6 @@ function noteFromScope(scope: Blockly.ContextMenuRegistry.Scope): Note | null { return comment instanceof Note ? comment : null; } -/** - * Runs a mutation as a single undoable step. - * - * @param mutate The mutation to perform. - */ -function asOneUndoStep(mutate: () => void): void { - const existingGroup = Blockly.Events.getGroup(); - if (!existingGroup) Blockly.Events.setGroup(true); - try { - mutate(); - } finally { - Blockly.Events.setGroup(existingGroup); - } -} - -/** - * Builds the row of colour swatches used as a menu item's display text. - * - * Blockly's context menu has no notion of submenus, but `displayText` accepts - * an HTMLElement — so the whole palette fits in one row rather than spilling - * seven entries into the menu. - * - * @param note The note to recolour. - * @param palette The - * swatches. - * @returns The swatch row. - */ -function createSwatchRow(note: Note, palette: PaletteEntry[]): HTMLElement { - const row = document.createElement('div'); - row.className = 'blocklyNoteSwatchRow'; - - for (const {name, fill} of palette) { - const swatch = document.createElement('button'); - swatch.type = 'button'; - swatch.className = 'blocklyNoteSwatch'; - // The swatch shows the note's colour itself, the way a block's colour - // reads in the toolbox. - swatch.style.backgroundColor = fill; - swatch.title = name; - swatch.setAttribute('aria-label', name); - if (note.getColour().toLowerCase() === fill.toLowerCase()) { - swatch.classList.add('blocklyNoteSwatchSelected'); - } - - swatch.addEventListener('pointerdown', (e) => { - // Stop the menu's own handler from also firing for this row. - e.stopPropagation(); - e.preventDefault(); - asOneUndoStep(() => note.setColour(fill)); - Blockly.ContextMenu.hide(); - }); - - row.appendChild(swatch); - } - - return row; -} - /** * Registers every note-related context menu item. * @@ -303,26 +236,3 @@ export function unregisterNoteContextMenu(): void { Blockly.ContextMenuItems.registerCommentCreate(); } } - -Blockly.Css.register(` -.blocklyNoteSwatchRow { - display: flex; - gap: 6px; - padding: 2px 0; -} - -.blocklyNoteSwatch { - width: 18px; - height: 18px; - padding: 0; - border: 1px solid rgba(0, 0, 0, 0.25); - border-radius: 4px; - cursor: pointer; -} - -/* #fc3 is the selection colour core uses for comments and blocks. */ -.blocklyNoteSwatchSelected { - outline: 2px solid #fc3; - outline-offset: 1px; -} -`); diff --git a/blockly-workspace-notes/src/css.ts b/blockly-workspace-notes/src/ui/css.ts similarity index 99% rename from blockly-workspace-notes/src/css.ts rename to blockly-workspace-notes/src/ui/css.ts index 3def4a4..1e08cc7 100644 --- a/blockly-workspace-notes/src/css.ts +++ b/blockly-workspace-notes/src/ui/css.ts @@ -23,16 +23,18 @@ import * as Blockly from 'blockly/core'; import { - BODY_INSET, NOTE_CLASS, PINNED_CLASS, RULE_CLASS, - SCROLLBAR_WIDTH, TITLED_CLASS, TITLE_CLASS, +} from '../constants/dom'; +import { + BODY_INSET, + SCROLLBAR_WIDTH, TITLE_FONT_SIZE, TOPBAR_HEIGHT, -} from './constants'; +} from '../constants/layout'; Blockly.Css.register(` /* diff --git a/blockly-workspace-notes/src/title_editor.ts b/blockly-workspace-notes/src/ui/title_editor.ts similarity index 93% rename from blockly-workspace-notes/src/title_editor.ts rename to blockly-workspace-notes/src/ui/title_editor.ts index bb4dbfe..85f3f4a 100644 --- a/blockly-workspace-notes/src/title_editor.ts +++ b/blockly-workspace-notes/src/ui/title_editor.ts @@ -20,13 +20,14 @@ import * as Blockly from 'blockly/core'; -import type {Note} from './note'; +import {TITLE_CLASS, UNTITLED_TITLE_TEXT} from '../constants/dom'; import { NOTE_MARGIN, TITLE_FONT_SIZE, TITLE_LINE_HEIGHT, - UNTITLED_TITLE_TEXT, -} from './constants'; +} from '../constants/layout'; +import type {Note} from '../model/note'; +import {asOneUndoStep} from '../utils/undo'; /** * Where the editor should sit, in viewport pixels. @@ -50,7 +51,7 @@ interface EditorBox { * @returns A DOMRect-like box, or null if nothing is rendered. */ function editorBox(note: Note): EditorBox | null { - const title = note.getSvgRoot().querySelector('.blocklyNoteTitle'); + const title = note.getSvgRoot().querySelector(`.${TITLE_CLASS}`); const box = title?.getBoundingClientRect(); if (!box?.width) return null; @@ -94,13 +95,8 @@ export function editTitle(note: Note): void { const commit = () => { if (cancelled || !input) return; - const existingGroup = Blockly.Events.getGroup(); - if (!existingGroup) Blockly.Events.setGroup(true); - try { - note.setTitle(input.value); - } finally { - Blockly.Events.setGroup(existingGroup); - } + const title = input.value; + asOneUndoStep(() => note.setTitle(title)); }; Blockly.WidgetDiv.show( diff --git a/blockly-workspace-notes/src/colour.ts b/blockly-workspace-notes/src/utils/colour.ts similarity index 96% rename from blockly-workspace-notes/src/colour.ts rename to blockly-workspace-notes/src/utils/colour.ts index bf86585..f73e7ba 100644 --- a/blockly-workspace-notes/src/colour.ts +++ b/blockly-workspace-notes/src/utils/colour.ts @@ -12,7 +12,11 @@ import * as Blockly from 'blockly/core'; -import {EDGE_VALUE_SCALE, NOTE_SATURATION, NOTE_VALUE} from './constants'; +import { + EDGE_VALUE_SCALE, + NOTE_SATURATION, + NOTE_VALUE, +} from '../constants/colours'; /** * Converts a hex colour to HSV. diff --git a/blockly-workspace-notes/src/utils/guards.ts b/blockly-workspace-notes/src/utils/guards.ts new file mode 100644 index 0000000..da4ff57 --- /dev/null +++ b/blockly-workspace-notes/src/utils/guards.ts @@ -0,0 +1,22 @@ +/** + * @fileoverview Recognising a note among a workspace's comments. + * + * A workspace hands back `WorkspaceComment`s, and a host can still create a + * plain one alongside its notes, so every place that walks the comment list + * narrows it through here first. + * + * The check is `instanceof` against both concrete classes rather than a duck + * type: a plain comment carrying a `title` property should not be mistaken for + * a note. + */ + +import {Note} from '../model/note'; +import {NoteComment} from '../model/note_comment'; + +/** + * @param candidate Any value. + * @returns Whether the value is a note. + */ +export function isNote(candidate: unknown): candidate is Note | NoteComment { + return candidate instanceof Note || candidate instanceof NoteComment; +} diff --git a/blockly-workspace-notes/src/utils/messages.ts b/blockly-workspace-notes/src/utils/messages.ts new file mode 100644 index 0000000..326c2f7 --- /dev/null +++ b/blockly-workspace-notes/src/utils/messages.ts @@ -0,0 +1,20 @@ +/** + * @fileoverview Reading translatable strings out of Blockly's message table. + */ + +import * as Blockly from 'blockly/core'; + +/** + * Reads a Blockly message with a fallback, since `blockly/core` on its own + * ships no message table. + * + * Every string this plugin shows goes through here, so a host that wants to + * translate one only has to set the matching key on `Blockly.Msg`. + * + * @param key The message key. + * @param fallback The text to use when the key is unset. + * @returns The message. + */ +export function msg(key: string, fallback: string): string { + return Blockly.Msg[key] || fallback; +} diff --git a/blockly-workspace-notes/src/utils/undo.ts b/blockly-workspace-notes/src/utils/undo.ts new file mode 100644 index 0000000..45b1068 --- /dev/null +++ b/blockly-workspace-notes/src/utils/undo.ts @@ -0,0 +1,26 @@ +/** + * @fileoverview Grouping several changes into one undoable step. + */ + +import * as Blockly from 'blockly/core'; + +/** + * Runs a mutation as a single undoable step. + * + * A menu action that touches two properties — pinning a note and raising it, + * say — would otherwise take two presses of undo to reverse. Joining an + * existing group rather than starting a new one matters when the caller is + * already inside one, such as during a paste. + * + * @param mutate The mutation to perform. + * @returns Whatever the mutation returned. + */ +export function asOneUndoStep(mutate: () => T): T { + const existingGroup = Blockly.Events.getGroup(); + if (!existingGroup) Blockly.Events.setGroup(true); + try { + return mutate(); + } finally { + Blockly.Events.setGroup(existingGroup); + } +} diff --git a/blockly-workspace-notes/src/xml.ts b/blockly-workspace-notes/src/xml.ts deleted file mode 100644 index 50bdd86..0000000 --- a/blockly-workspace-notes/src/xml.ts +++ /dev/null @@ -1,325 +0,0 @@ -/** - * @fileoverview XML serialization for notes, for hosts still on the older - * `Blockly.Xml` API. - * - * Blockly 13 still writes and reads workspace comments as XML, so without - * this a note saved through `Blockly.Xml.workspaceToDom` would silently lose - * its title, colour, pinned state and metadata, and would load back as a - * plain comment — `Xml.loadWorkspaceComment` hardcodes - * `new RenderedWorkspaceComment(...)` instead of going through - * `workspace.newComment()`, so the plugin's usual hook does not reach it. - * - * There is no registry for XML the way there is for JSON serializers, so the - * entry points on the `Blockly.Xml` namespace are wrapped instead. Each has to - * be wrapped individually: Blockly's own `appendDomToWorkspace` and - * `clearWorkspaceAndLoadFromXml` call `domToWorkspace` through a module-local - * binding, so patching that one export does not reach them. - * - * Everything here funnels through the same state objects the JSON serializer - * uses, so the two formats cannot drift apart. - */ - -import * as Blockly from 'blockly/core'; - -import type {SavedNote} from './types'; -import {Note, NoteComment, isNote} from './note'; -import {appendNote, saveNote} from './serializer'; - -/** The entry points wrapped on the Blockly.Xml namespace. */ -const PATCHED = [ - 'workspaceToDom', - 'domToWorkspace', - 'appendDomToWorkspace', - 'clearWorkspaceAndLoadFromXml', - 'saveWorkspaceComment', - 'loadWorkspaceComment', -] as const; - -/** One of the six names above. */ -type PatchedName = (typeof PATCHED)[number]; - -/** The six entry points, as a writable record. */ -type PatchedXml = {-readonly [K in PatchedName]: (typeof Blockly.Xml)[K]}; - -/** - * `Blockly.Xml` is an ES module namespace object, so TypeScript treats its - * members as read-only. Replacing them is exactly what this module does — - * there is no registry for XML the way there is for JSON serializers — so the - * namespace is viewed through one mutable alias, declared once, here. Each - * assignment below is still checked against Blockly's real signature. - */ -const Xml = Blockly.Xml as typeof Blockly.Xml & PatchedXml; - -/** - * Converts a note's JSON state into attributes on a `` element. - * - * Only the note-specific fields are written; core has already supplied - * `id`/`x`/`y`/`w`/`h` and the text content. Everything added here is - * optional, so older Blockly reads the element as an ordinary comment and - * simply ignores what it does not recognise. - * - * @param elem The `` element to decorate. - * @param state The note state, as produced by `saveNote`. - */ -function decorateElement(elem: Element, state: SavedNote): void { - if (state.title) elem.setAttribute('title', state.title); - if (state.colour) elem.setAttribute('colour', state.colour); - if (state.pinned) elem.setAttribute('pinned', 'true'); - // `z` rather than `zIndex`, grouping it with core's terse x/y/w/h geometry. - if (state.zIndex) elem.setAttribute('z', `${state.zIndex}`); - if (state.meta?.author) elem.setAttribute('author', state.meta.author); - if (state.meta?.createdAt) elem.setAttribute('created', state.meta.createdAt); - if (state.meta?.updatedAt) elem.setAttribute('updated', state.meta.updatedAt); -} - -/** - * Reads a `` element into the state shape the JSON loader uses. - * - * Mirrors core's `loadWorkspaceComment` for the shared attributes, including - * its `isNaN` guards, so a malformed value is skipped rather than written as - * NaN. The RTL flip is left to `appendNote`, which already applies it. - * - * @param elem A `` element. - * @returns The note state. - */ -export function domToNoteState(elem: Element): SavedNote { - const state: SavedNote = {} as SavedNote; - - const id = elem.getAttribute('id'); - if (id) state.id = id; - - const x = parseInt(elem.getAttribute('x') ?? '', 10); - const y = parseInt(elem.getAttribute('y') ?? '', 10); - if (!isNaN(x) && !isNaN(y)) { - state.x = x; - state.y = y; - } - - const width = parseInt(elem.getAttribute('w') ?? '', 10); - const height = parseInt(elem.getAttribute('h') ?? '', 10); - if (!isNaN(width) && !isNaN(height)) { - state.width = width; - state.height = height; - } - - if (elem.textContent) state.text = elem.textContent; - if (elem.getAttribute('collapsed') === 'true') state.collapsed = true; - if (elem.getAttribute('editable') === 'false') state.editable = false; - if (elem.getAttribute('movable') === 'false') state.movable = false; - if (elem.getAttribute('deletable') === 'false') state.deletable = false; - - const title = elem.getAttribute('title'); - if (title) state.title = title; - - const colour = elem.getAttribute('colour'); - if (colour) state.colour = colour; - - if (elem.getAttribute('pinned') === 'true') state.pinned = true; - - const zIndex = parseInt(elem.getAttribute('z') ?? '', 10); - if (!isNaN(zIndex)) state.zIndex = zIndex; - - const meta = { - author: elem.getAttribute('author') ?? '', - createdAt: elem.getAttribute('created') ?? '', - updatedAt: elem.getAttribute('updated') ?? '', - }; - if (Object.values(meta).some((value) => value)) state.meta = meta; - - return state; -} - -/** - * Serializes a note to a `` element. - * - * @param note The note to save. - * @param [skipId] True to omit the note's ID. - * @returns The `` element. - */ -export function noteToDom(note: Note | NoteComment, skipId = false): Element { - const elem = Blockly.utils.xml.createElement('comment'); - const state = saveNote(note, {addCoordinates: true, saveIds: !skipId}); - - if (state.id) elem.setAttribute('id', state.id); - elem.setAttribute('x', `${state.x}`); - elem.setAttribute('y', `${state.y}`); - elem.setAttribute('w', `${state.width}`); - elem.setAttribute('h', `${state.height}`); - - if (state.text) elem.textContent = state.text; - if (state.collapsed) elem.setAttribute('collapsed', 'true'); - if (state.editable === false) elem.setAttribute('editable', 'false'); - if (state.movable === false) elem.setAttribute('movable', 'false'); - if (state.deletable === false) elem.setAttribute('deletable', 'false'); - - decorateElement(elem, state); - return elem; -} - -/** - * Loads a `` element as a note. - * - * @param elem A `` element. - * @param workspace The workspace to load into. - * @returns The created note. - */ -export function domToNote( - elem: Element, - workspace: Blockly.Workspace, -): Note | NoteComment { - return appendNote(domToNoteState(elem), workspace, { - // Core's XML loader does not force recordUndo the way the JSON loader - // does, so honour whatever the caller has set. - recordUndo: Blockly.Events.getRecordUndo(), - }); -} - -/** - * @param xml An `` element. - * @returns Its direct `` children. Block comments - * are nested inside `` and so are untouched. - */ -function topLevelComments(xml: Element): Element[] { - return Array.from(xml.childNodes).filter( - (node): node is Element => node.nodeName.toLowerCase() === 'comment', - ); -} - -/** - * Returns a copy of an `` element with its top-level comments removed, - * alongside the removed elements. - * - * Loading is delegated to Blockly for everything except comments, and the - * notes are created afterwards. The caller's DOM is cloned rather than - * mutated, since callers commonly reuse the same document. - * - * @param xml An `` element. - * @returns The split. - */ -function splitComments(xml: Element): {stripped: Element; comments: Element[]} { - const stripped = xml.cloneNode(true) as Element; - const comments = topLevelComments(stripped); - for (const elem of comments) stripped.removeChild(elem); - return {stripped, comments}; -} - -/** The original Blockly.Xml functions, kept so they can be restored. */ -let originals: PatchedXml | null = null; - -/** - * Reference count, since Blockly.Xml is global but the plugin is per - * workspace: the wrappers stay in place until the last instance goes away. - */ -let registrationCount = 0; - -/** - * Wraps the Blockly.Xml entry points so notes survive an XML round trip. - */ -export function registerXmlSupport(): void { - if (registrationCount++) return; - - originals = { - workspaceToDom: Xml.workspaceToDom, - domToWorkspace: Xml.domToWorkspace, - appendDomToWorkspace: Xml.appendDomToWorkspace, - clearWorkspaceAndLoadFromXml: Xml.clearWorkspaceAndLoadFromXml, - saveWorkspaceComment: Xml.saveWorkspaceComment, - loadWorkspaceComment: Xml.loadWorkspaceComment, - }; - // Captured non-null: the wrappers below only exist while this is populated, - // which the module-level `let` cannot express. - const saved = originals; - - /** - * Runs a load through Blockly with comments held back, then adds the notes. - * Wrapped in one event group so a whole XML load is a single undo step; - * Blockly's own loader reuses an open group rather than starting its own. - * - * @param load The original Blockly loader to delegate to. - * @param xml The XML being loaded. - * @param workspace The target workspace. - * @returns The new block IDs, from Blockly. - */ - const loadWithNotes = ( - load: (xml: Element, workspace: W) => string[], - xml: Element, - workspace: W, - ): string[] => { - const {stripped, comments} = splitComments(xml); - const existingGroup = Blockly.Events.getGroup(); - if (!existingGroup) Blockly.Events.setGroup(true); - try { - const blockIds = load.call(Xml, stripped, workspace); - for (const elem of comments) domToNote(elem, workspace); - return blockIds; - } finally { - Blockly.Events.setGroup(existingGroup); - } - }; - - Xml.workspaceToDom = function (workspace, skipId = false) { - const dom = saved.workspaceToDom.call(Xml, workspace, skipId); - // Blockly emits one per top comment, in this same order, so the - // two line up by index. Only extra attributes are added, leaving Blockly's - // own output — including its RTL handling — exactly as it was. - const notes = workspace.getTopComments(); - topLevelComments(dom).forEach((elem, i) => { - const note = notes[i]; - if (isNote(note)) { - decorateElement(elem, saveNote(note, {addCoordinates: true})); - } - }); - return dom; - }; - - Xml.domToWorkspace = function (xml, workspace) { - return loadWithNotes(saved.domToWorkspace, xml, workspace); - }; - - // Blockly's own versions of these two reach domToWorkspace through a - // module-local binding, so the patch above never runs for them. Delegating - // the stripped XML to the originals keeps their extra behaviour — the - // block-offset maths in append, the clear in the other — intact. - Xml.appendDomToWorkspace = function (xml, workspace) { - return loadWithNotes(saved.appendDomToWorkspace, xml, workspace); - }; - - Xml.clearWorkspaceAndLoadFromXml = function (xml, workspace) { - return loadWithNotes(saved.clearWorkspaceAndLoadFromXml, xml, workspace); - }; - - // Direct callers of the per-comment helpers get note support too. - Xml.saveWorkspaceComment = function (comment, skipId = false) { - return isNote(comment) - ? noteToDom(comment, skipId) - : saved.saveWorkspaceComment.call(Xml, comment, skipId); - }; - - Xml.loadWorkspaceComment = function (elem, workspace) { - return domToNote(elem, workspace); - }; -} - -/** - * Puts one saved function back. - * - * Generic so the assignment is `PatchedXml[K] = PatchedXml[K]` for a single - * `K`, which TypeScript accepts; indexing with the whole union would not - * correlate the two sides. - * - * @param name Which entry point to restore. - * @param saved The saved originals. - */ -function restoreOne(name: K, saved: PatchedXml): void { - const target: PatchedXml = Xml; - target[name] = saved[name]; -} - -/** Restores Blockly's own XML functions. */ -export function unregisterXmlSupport(): void { - if (--registrationCount > 0) return; - registrationCount = 0; - if (!originals) return; - for (const name of PATCHED) restoreOne(name, originals); - originals = null; -} diff --git a/blockly-workspace-notes/test/colour.mocha.js b/blockly-workspace-notes/test/colour.mocha.js index aa683f2..fe46745 100644 --- a/blockly-workspace-notes/test/colour.mocha.js +++ b/blockly-workspace-notes/test/colour.mocha.js @@ -9,8 +9,8 @@ import {assert} from 'chai'; -import {DEFAULT_PALETTE} from '../src/constants'; -import {colourForHue, edgeFor, hexToHsv} from '../src/colour'; +import {DEFAULT_PALETTE} from '../src/index'; +import {colourForHue, edgeFor, hexToHsv} from '../src/utils/colour'; /** * @param {string} hex A hex colour. diff --git a/blockly-workspace-notes/test/note.mocha.js b/blockly-workspace-notes/test/note.mocha.js index 02e0e0d..93ee733 100644 --- a/blockly-workspace-notes/test/note.mocha.js +++ b/blockly-workspace-notes/test/note.mocha.js @@ -10,9 +10,8 @@ import * as Blockly from 'blockly/core'; import {assert} from 'chai'; -import {DEFAULT_COLOUR} from '../src/constants'; -import {NoteChange} from '../src/events'; -import {NoteComment, isNote, nextZIndex, previousZIndex} from '../src/note'; +import {DEFAULT_COLOUR, NoteChange, NoteComment, isNote} from '../src/index'; +import {nextZIndex, previousZIndex} from '../src/model/stacking'; suite('Note model', function () { setup(function () { diff --git a/blockly-workspace-notes/test/serializer.mocha.js b/blockly-workspace-notes/test/serializer.mocha.js index dccfe08..882026e 100644 --- a/blockly-workspace-notes/test/serializer.mocha.js +++ b/blockly-workspace-notes/test/serializer.mocha.js @@ -9,11 +9,13 @@ import {assert} from 'chai'; import { DEFAULT_COLOUR, NOTE_SERIALIZER_NAME, + NoteComment, SCHEMA_VERSION, -} from '../src/constants'; -import {NoteComment, isNote} from '../src/note'; -import {WorkspaceNotes} from '../src/index'; -import {migrate, saveNote} from '../src/serializer'; + WorkspaceNotes, + isNote, + migrate, + saveNote, +} from '../src/index'; /** * Blockly queues events and flushes them on a later macrotask, so the undo diff --git a/blockly-workspace-notes/test/xml.mocha.js b/blockly-workspace-notes/test/xml.mocha.js index 2d4739c..57c5e3e 100644 --- a/blockly-workspace-notes/test/xml.mocha.js +++ b/blockly-workspace-notes/test/xml.mocha.js @@ -9,10 +9,14 @@ import * as Blockly from 'blockly/core'; import {assert} from 'chai'; -import {DEFAULT_COLOUR} from '../src/constants'; -import {NoteComment, isNote} from '../src/note'; -import {WorkspaceNotes} from '../src/index'; -import {domToNoteState, noteToDom} from '../src/xml'; +import { + DEFAULT_COLOUR, + NoteComment, + WorkspaceNotes, + domToNoteState, + isNote, + noteToDom, +} from '../src/index'; /** * @param {!Element} dom An `` element. From 7f997b9bf36826be88d54c958ee5a1ae9b2dbcb9 Mon Sep 17 00:00:00 2001 From: Preet Vadaliya Date: Tue, 8 Sep 2026 20:03:38 +0200 Subject: [PATCH 03/20] style: pin prettier options and hold every line to 80 columns --- blockly-workspace-notes/.prettierrc.json | 13 +++++++++++-- blockly-workspace-notes/src/types/note.ts | 5 +++-- blockly-workspace-notes/src/ui/css.ts | 10 +++++++--- blockly-workspace-notes/test/colour.mocha.js | 2 +- blockly-workspace-notes/test/note.mocha.js | 2 +- blockly-workspace-notes/test/serializer.mocha.js | 4 ++-- blockly-workspace-notes/test/xml.mocha.js | 4 ++-- 7 files changed, 27 insertions(+), 13 deletions(-) diff --git a/blockly-workspace-notes/.prettierrc.json b/blockly-workspace-notes/.prettierrc.json index 0e56068..a68f41b 100644 --- a/blockly-workspace-notes/.prettierrc.json +++ b/blockly-workspace-notes/.prettierrc.json @@ -1,5 +1,14 @@ { - "bracketSpacing": false, + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "semi": true, "singleQuote": true, - "quoteProps": "preserve" + "quoteProps": "preserve", + "trailingComma": "all", + "bracketSpacing": false, + "bracketSameLine": false, + "arrowParens": "always", + "proseWrap": "preserve", + "endOfLine": "lf" } diff --git a/blockly-workspace-notes/src/types/note.ts b/blockly-workspace-notes/src/types/note.ts index 1415c1d..feed9d0 100644 --- a/blockly-workspace-notes/src/types/note.ts +++ b/blockly-workspace-notes/src/types/note.ts @@ -54,8 +54,9 @@ export type NotePropertyValue = * Everything the note mixin adds to a workspace comment. * * Declared separately from the mixin because TypeScript cannot name an - * anonymous class expression in a `.d.ts`. Without this interface, `declaration: - * true` fails on `NoteComment` and `Note` with "has or is using private name". + * anonymous class expression in a `.d.ts`. Without this interface, + * `declaration: true` fails on `NoteComment` and `Note` with "has or is using + * private name". */ export interface NoteSurface { getNoteState(): NoteState; diff --git a/blockly-workspace-notes/src/ui/css.ts b/blockly-workspace-notes/src/ui/css.ts index 1e08cc7..0d93c48 100644 --- a/blockly-workspace-notes/src/ui/css.ts +++ b/blockly-workspace-notes/src/ui/css.ts @@ -211,7 +211,9 @@ Blockly.Css.register(` * The type has to be set here for the same reason, and it is the sharper of * the two traps: alongside that fill rule the renderer writes * - * .thrasos-renderer.classic-theme .blocklyText { font: normal 11pt sans-serif; } + * .thrasos-renderer.classic-theme .blocklyText { + * font: normal 11pt sans-serif; + * } * * and font is a SHORTHAND, so it resets font-weight and font-size to the * theme's field values every time. A plain .blocklyNoteTitle { font-weight: @@ -229,7 +231,8 @@ Blockly.Css.register(` * An unnamed note shows a placeholder rather than an empty row, greyed so it * reads as a prompt and not as a title someone typed. */ -.${NOTE_CLASS}.blocklyComment:not(.${TITLED_CLASS}) .${TITLE_CLASS}.blocklyText { +.${NOTE_CLASS}.blocklyComment:not(.${TITLED_CLASS}) + .${TITLE_CLASS}.blocklyText { fill: #999; } @@ -307,7 +310,8 @@ Blockly.Css.register(` stroke-width: 3px; } -.blocklySelected.${NOTE_CLASS}.blocklyCollapsed .blocklyCommentTopbarBackground { +.blocklySelected.${NOTE_CLASS}.blocklyCollapsed + .blocklyCommentTopbarBackground { stroke: none; } `); diff --git a/blockly-workspace-notes/test/colour.mocha.js b/blockly-workspace-notes/test/colour.mocha.js index fe46745..3c4b603 100644 --- a/blockly-workspace-notes/test/colour.mocha.js +++ b/blockly-workspace-notes/test/colour.mocha.js @@ -83,7 +83,7 @@ suite('Note colours', function () { } }); - test('the palette is stored as the colours its own hues produce', function () { + test('the palette stores the colours its own hues produce', function () { for (const {name, hue, fill} of DEFAULT_PALETTE) { // Grey is the exception: it is deliberately hueless, so no hue // reproduces it. diff --git a/blockly-workspace-notes/test/note.mocha.js b/blockly-workspace-notes/test/note.mocha.js index 93ee733..72b7e7a 100644 --- a/blockly-workspace-notes/test/note.mocha.js +++ b/blockly-workspace-notes/test/note.mocha.js @@ -148,7 +148,7 @@ suite('Note model', function () { assert.equal(this.note.getMeta().author, 'x'); }); - test("'*' with null is a no-op, so a delete snapshot replays safely", function () { + test("'*' with null is a no-op, so a delete snapshot replays", function () { this.note.setTitle('Keep me'); this.note.applyNoteProperty('*', null); assert.equal(this.note.getTitle(), 'Keep me'); diff --git a/blockly-workspace-notes/test/serializer.mocha.js b/blockly-workspace-notes/test/serializer.mocha.js index 882026e..bd84b22 100644 --- a/blockly-workspace-notes/test/serializer.mocha.js +++ b/blockly-workspace-notes/test/serializer.mocha.js @@ -264,7 +264,7 @@ suite('Note serialization', function () { assert.lengthOf(state[NOTE_SERIALIZER_NAME].notes, 1); }); - test('a note already loaded wins over a duplicate legacy entry', function () { + test('a loaded note wins over a duplicate legacy entry', function () { Blockly.serialization.workspaces.load( { workspaceNotes: { @@ -324,7 +324,7 @@ suite('Note serialization', function () { assert.isEmpty(this.workspace.getTopComments(false)); }); - test('plain comments are cleared too, so they cannot accumulate', function () { + test('plain comments are cleared too, so they cannot pile up', function () { new Blockly.comments.WorkspaceComment(this.workspace); Blockly.serialization.workspaces.load({}, this.workspace); assert.isEmpty(this.workspace.getTopComments(false)); diff --git a/blockly-workspace-notes/test/xml.mocha.js b/blockly-workspace-notes/test/xml.mocha.js index 57c5e3e..9a343cf 100644 --- a/blockly-workspace-notes/test/xml.mocha.js +++ b/blockly-workspace-notes/test/xml.mocha.js @@ -108,7 +108,7 @@ suite('Note XML serialization', function () { assert.equal(elem.getAttribute('title'), 'T'); }); - test('attributes line up when several notes are saved at once', function () { + test('attributes line up when several notes are saved', function () { makeNote(this.workspace, {title: 'first', colour: '#ffd6a5'}); makeNote(this.workspace, {title: 'second', colour: '#c7e4ff'}); makeNote(this.workspace, {title: 'third'}); @@ -374,7 +374,7 @@ suite('Note XML serialization', function () { }); }); - test('malformed geometry is skipped rather than stored as NaN', function () { + test('malformed geometry is skipped, not stored as NaN', function () { const dom = Blockly.utils.xml.textToDom( '', ); From 0eb5e943ba961f7983bcc7ce9d8b1cee4d7171c1 Mon Sep 17 00:00:00 2001 From: Preet Vadaliya Date: Tue, 8 Sep 2026 20:05:55 +0200 Subject: [PATCH 04/20] docs: rewrite the plugin README --- blockly-workspace-notes/README.md | 143 ++++++++++++++++---------- blockly-workspace-notes/src/plugin.ts | 9 +- 2 files changed, 94 insertions(+), 58 deletions(-) diff --git a/blockly-workspace-notes/README.md b/blockly-workspace-notes/README.md index 242d00f..cfdcc3c 100644 --- a/blockly-workspace-notes/README.md +++ b/blockly-workspace-notes/README.md @@ -1,63 +1,28 @@ -

- blockly-workspace-notes -
- TypeScript - Node.js - NPM -
- Blockly - License -

- -**blockly-workspace-notes** turns Blockly's workspace comments into sticky notes: draggable, resizable, colour-coded paper with a title, an author and a stacking order. They extend Blockly's own comments, so dragging, selection, keyboard navigation and undo all come for free, and they round-trip through both JSON and XML. +# Workspace Notes + +[![Built on Blockly](https://tinyurl.com/built-on-blockly)](https://github.com/google/blockly) + +Sticky notes for a Blockly workspace: draggable, resizable, colour-coded paper +with a title, an author and a stacking order.

The parts of a note

-## Core Dependencies - -Before setting up the project, ensure you have the following installed: - -1. **Node.js** — `v20+`   [Download Node.js](https://nodejs.org/) -2. **NPM** — `v10+`   [Learn about NPM](https://www.npmjs.com/) -3. **Blockly** — `v13.2.1` (peer dependency)   [Blockly docs](https://developers.google.com/blockly) - -> [!IMPORTANT] -> **Import the plugin before `Blockly.inject`.** -> -> `Blockly.Css.register` only affects injections that happen after it runs, so a -> note imported later renders unstyled. Notes are saved under their own -> versioned `workspaceNotes` key alongside `blocks`; older files written under -> `workspaceComments` still load. - -## Repository Structure - -```plaintext -| -├── 📁 .github # CI and publish workflows -├── 📁 docs # README media -├── 📁 src # Plugin source, TypeScript -├── 📁 test # Mocha suites and the playground -├── 📄 .gitignore -├── 📄 .prettierrc.json # Format config -├── 📄 eslint.config.mjs # Lint config -├── 📄 tsconfig.json # TypeScript config -├── 📄 DESIGN.md # Design decisions and rationale -├── 📄 LICENSE -├── 📄 package.json -└── 📄 README.md # Project overview -| -``` - -## Getting Started +Notes extend Blockly's own workspace comments, so dragging, resizing, +selection, keyboard navigation and undo all work exactly as they already do. +They are saved alongside your blocks, and round-trip through both JSON and XML. -### Use it in an application +## Install ```bash npm install @mit-app-inventor/blockly-workspace-notes ``` +Needs Blockly 13.2.1 or newer as a peer dependency, and Node 20+ to build. + +## Use + ```js import * as Blockly from 'blockly'; import {WorkspaceNotes} from '@mit-app-inventor/blockly-workspace-notes'; @@ -66,9 +31,78 @@ const workspace = Blockly.inject('blocklyDiv', {toolbox}); new WorkspaceNotes(workspace).init(); ``` -Right-click the workspace to add a note; right-click a note to rename, recolour, pin or restack it. +> [!IMPORTANT] +> Import the plugin **before** calling `Blockly.inject`. `Blockly.Css.register` +> only affects injections that happen after it runs, so a note imported later +> renders unstyled. + +That is the whole setup. Right-click the workspace to add a note; click a +note's title to rename it; right-click a note to recolour, pin, collapse or +restack it. Everything else — moving, resizing, copying, deleting, undo — is +Blockly's own behaviour. -### Work on it locally +## Options + +Pass an options object as the second argument. + +| Option | Default | What it does | +| ---------------------------- | -------------------- | -------------------------------------------------------- | +| `palette` | 7 stationery colours | The swatches offered in the Colour menu | +| `defaultSize` | `260 × 180` | Size a new note is created at | +| `getAuthor` | `() => ''` | Called once per note to record who made it | +| `contextMenu` | `true` | Register the note menu items | +| `xmlSupport` | `true` | Wrap `Blockly.Xml` so notes survive the older XML format | +| `skipSerializerRegistration` | `false` | Leave persistence entirely to your app | +| `emitLegacyComments` | `false` | Also write the old `workspaceComments` key | + +```js +new WorkspaceNotes(workspace, { + defaultSize: {width: 300, height: 200}, + getAuthor: () => currentUser.name, +}).init(); +``` + +## API + +| Member | What it does | +| -------------------- | --------------------------------------------------- | +| `init()` | Starts the plugin. Idempotent. | +| `dispose()` | Stops it and restores everything Blockly had before | +| `createNote(state?)` | Adds a note, in front, as one undo step | +| `getNotes()` | Every note on the workspace | + +The note classes, serializers and helpers are exported too, for an app that +drives saving and loading itself. + +## Saved format + +Notes are saved under their own versioned `workspaceNotes` key, beside +`blocks`. Older files written under `workspaceComments` still load, as notes. + +```json +{ + "workspaceNotes": { + "version": 1, + "notes": [ + { + "id": "n1qX", + "x": 40, + "y": 20, + "width": 240, + "height": 140, + "text": "Refactor this loop", + "title": "TODO", + "colour": "#ffd6a5" + } + ] + } +} +``` + +Only what differs from the default is written, so a plain yellow note with no +title records neither. + +## Develop ```bash git clone https://github.com/mit-cml/blockly-plugins.git @@ -86,4 +120,9 @@ npm run lint # ESLint npm run format # Prettier ``` -

Built with :heart: for Blockly

+[DESIGN.md](./DESIGN.md) covers why a note works the way it does, and how the +source is laid out. + +## License + +Apache-2.0 diff --git a/blockly-workspace-notes/src/plugin.ts b/blockly-workspace-notes/src/plugin.ts index fa8542d..9a9582e 100644 --- a/blockly-workspace-notes/src/plugin.ts +++ b/blockly-workspace-notes/src/plugin.ts @@ -41,6 +41,7 @@ import { unregisterNoteContextMenu, } from './ui/context_menu'; import {isNote} from './utils/guards'; +import {asOneUndoStep} from './utils/undo'; /** * Adds note support to a workspace. @@ -165,9 +166,7 @@ export class WorkspaceNotes { * @returns The new note. */ createNote(state: Partial = {}): Note | NoteComment { - const existingGroup = Blockly.Events.getGroup(); - if (!existingGroup) Blockly.Events.setGroup(true); - try { + return asOneUndoStep(() => { const note = makeNote(this.workspace); if (state.text) note.setText(state.text); if (state.title) note.setTitle(state.title); @@ -178,9 +177,7 @@ export class WorkspaceNotes { note.setZIndex(nextZIndex(this.workspace)); note.restoreMeta({author: this.options.getAuthor()}); return note; - } finally { - Blockly.Events.setGroup(existingGroup); - } + }); } /** From 23fb54104f4ed607a82d782c3e243763a3650f07 Mon Sep 17 00:00:00 2001 From: Preet Vadaliya Date: Tue, 8 Sep 2026 21:53:59 +0200 Subject: [PATCH 05/20] fix: restore Blockly's default comment size on dispose --- .../src/model/default_size.ts | 52 +++++++ blockly-workspace-notes/src/plugin.ts | 14 +- blockly-workspace-notes/test/plugin.mocha.js | 128 ++++++++++++++++++ 3 files changed, 187 insertions(+), 7 deletions(-) create mode 100644 blockly-workspace-notes/src/model/default_size.ts create mode 100644 blockly-workspace-notes/test/plugin.mocha.js diff --git a/blockly-workspace-notes/src/model/default_size.ts b/blockly-workspace-notes/src/model/default_size.ts new file mode 100644 index 0000000..60c3b1d --- /dev/null +++ b/blockly-workspace-notes/src/model/default_size.ts @@ -0,0 +1,52 @@ +/** + * @fileoverview The size a new note is created at. + * + * Blockly reads this from `CommentView.defaultCommentSize`, a static on core, + * when it builds a comment with no size of its own. Core's value is 120x100 — + * sized for a comment whose whole chrome is a 24px bar, which a note's margins + * would leave barely two lines of writing. + * + * Because it is a static rather than anything owned by a workspace, it is + * swapped the way this plugin swaps every other global: reference-counted, so + * core's own value comes back only once the last plugin instance is gone. Two + * workspaces on one page therefore share one default — the first one to start + * wins, the same rule `registerNoteSerializers` applies to its options — and, + * more importantly, disposing one plugin cannot resize the notes of another + * that is still running. + */ + +import * as Blockly from 'blockly/core'; + +/** How many plugin instances are currently holding the swap. */ +let registrationCount = 0; + +/** Core's own default, kept so it can be put back exactly as it was. */ +let displacedSize: Blockly.utils.Size | null = null; + +/** + * Makes new notes default to the given size. + * + * @param size The size a note with no saved size should be created at. + * @param size.width Width in workspace units. + * @param size.height Height in workspace units. + */ +export function applyDefaultNoteSize(size: { + width: number; + height: number; +}): void { + if (registrationCount++) return; + displacedSize = Blockly.comments.CommentView.defaultCommentSize; + Blockly.comments.CommentView.defaultCommentSize = new Blockly.utils.Size( + size.width, + size.height, + ); +} + +/** Restores the default size core shipped with. */ +export function restoreDefaultNoteSize(): void { + if (--registrationCount > 0) return; + registrationCount = 0; + if (!displacedSize) return; + Blockly.comments.CommentView.defaultCommentSize = displacedSize; + displacedSize = null; +} diff --git a/blockly-workspace-notes/src/plugin.ts b/blockly-workspace-notes/src/plugin.ts index 9a9582e..79be6b9 100644 --- a/blockly-workspace-notes/src/plugin.ts +++ b/blockly-workspace-notes/src/plugin.ts @@ -17,6 +17,10 @@ import * as Blockly from 'blockly/core'; import './ui/css'; import {DEFAULT_PALETTE} from './constants/colours'; import {DEFAULT_SIZE} from './constants/layout'; +import { + applyDefaultNoteSize, + restoreDefaultNoteSize, +} from './model/default_size'; import {Note} from './model/note'; import {NoteComment} from './model/note_comment'; import {nextZIndex} from './model/stacking'; @@ -94,13 +98,7 @@ export class WorkspaceNotes { if (this.initialized_) return; this.initialized_ = true; - // Blockly's own default is sized for a comment whose whole chrome is a - // 24px bar; a note's margins would leave that barely two lines. - const {width, height} = this.options.defaultSize; - Blockly.comments.CommentView.defaultCommentSize = new Blockly.utils.Size( - width, - height, - ); + applyDefaultNoteSize(this.options.defaultSize); // Patched on the instance rather than the prototype: scoped to this // workspace and trivially reversible. This is what makes undoing a delete @@ -144,6 +142,8 @@ export class WorkspaceNotes { this.originalNewComment_ = null; } + restoreDefaultNoteSize(); + if (!this.options.skipSerializerRegistration) { unregisterNoteSerializers(); } diff --git a/blockly-workspace-notes/test/plugin.mocha.js b/blockly-workspace-notes/test/plugin.mocha.js new file mode 100644 index 0000000..d6d3c28 --- /dev/null +++ b/blockly-workspace-notes/test/plugin.mocha.js @@ -0,0 +1,128 @@ +/** + * @fileoverview Lifecycle tests: what `init` replaces, and whether `dispose` + * gives all of it back. + * + * The registries have their own coverage in the suites that use them. What is + * checked here is the state that is *not* a registry — the statics and + * instance properties the plugin writes on core — because nothing else would + * notice if one of them were left behind. + */ + +import * as Blockly from 'blockly/core'; +import {assert} from 'chai'; + +import {WorkspaceNotes} from '../src/index'; + +suite('Plugin lifecycle', function () { + setup(function () { + this.workspace = new Blockly.Workspace(); + }); + + teardown(function () { + this.workspace.dispose(); + }); + + suite('defaultCommentSize', function () { + test('init applies the configured default size', function () { + const plugin = new WorkspaceNotes(this.workspace, { + contextMenu: false, + defaultSize: {width: 300, height: 200}, + }); + plugin.init(); + try { + const size = Blockly.comments.CommentView.defaultCommentSize; + assert.equal(size.width, 300); + assert.equal(size.height, 200); + } finally { + plugin.dispose(); + } + }); + + test('dispose puts core’s own default back', function () { + const original = Blockly.comments.CommentView.defaultCommentSize; + + const plugin = new WorkspaceNotes(this.workspace, {contextMenu: false}); + plugin.init(); + assert.notEqual( + Blockly.comments.CommentView.defaultCommentSize, + original, + 'init should have replaced the default', + ); + + plugin.dispose(); + assert.strictEqual( + Blockly.comments.CommentView.defaultCommentSize, + original, + 'a disposed plugin must not leave core resized', + ); + }); + + test('a live plugin keeps its size when another is disposed', function () { + // The size is a static shared by every workspace on the page, so the + // swap is reference-counted: disposing one plugin must not resize the + // notes of another that is still running. + const original = Blockly.comments.CommentView.defaultCommentSize; + + const first = new WorkspaceNotes(this.workspace, { + contextMenu: false, + defaultSize: {width: 300, height: 200}, + }); + const other = new Blockly.Workspace(); + const second = new WorkspaceNotes(other, { + contextMenu: false, + defaultSize: {width: 400, height: 250}, + }); + + first.init(); + second.init(); + try { + first.dispose(); + const size = Blockly.comments.CommentView.defaultCommentSize; + assert.equal(size.width, 300, 'the live plugin still needs a size'); + assert.equal(size.height, 200); + + second.dispose(); + assert.strictEqual( + Blockly.comments.CommentView.defaultCommentSize, + original, + 'the last one out restores core', + ); + } finally { + other.dispose(); + } + }); + + test('disposing twice does not restore over a live plugin', function () { + const first = new WorkspaceNotes(this.workspace, {contextMenu: false}); + first.init(); + first.dispose(); + + const second = new WorkspaceNotes(this.workspace, { + contextMenu: false, + defaultSize: {width: 400, height: 250}, + }); + second.init(); + try { + first.dispose(); + const size = Blockly.comments.CommentView.defaultCommentSize; + assert.equal(size.width, 400); + assert.equal(size.height, 250); + } finally { + second.dispose(); + } + }); + }); + + suite('newComment', function () { + test('dispose restores the workspace’s own factory', function () { + const original = this.workspace.newComment; + + const plugin = new WorkspaceNotes(this.workspace, {contextMenu: false}); + plugin.init(); + assert.notEqual(this.workspace.newComment, original); + + plugin.dispose(); + assert.strictEqual(this.workspace.newComment, original); + }); + }); +}); From 4148255208e1603d02d5510e3df7bd5a1e94f8e9 Mon Sep 17 00:00:00 2001 From: Preet Vadaliya Date: Tue, 8 Sep 2026 21:55:22 +0200 Subject: [PATCH 06/20] chore: start the scoped package at 0.1.0 --- blockly-workspace-notes/package-lock.json | 4 ++-- blockly-workspace-notes/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/blockly-workspace-notes/package-lock.json b/blockly-workspace-notes/package-lock.json index 549981e..59dfcad 100644 --- a/blockly-workspace-notes/package-lock.json +++ b/blockly-workspace-notes/package-lock.json @@ -1,12 +1,12 @@ { "name": "@mit-app-inventor/blockly-workspace-notes", - "version": "0.4.0", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mit-app-inventor/blockly-workspace-notes", - "version": "0.4.0", + "version": "0.1.0", "license": "Apache-2.0", "devDependencies": { "@blockly/dev-scripts": "^13.2.0", diff --git a/blockly-workspace-notes/package.json b/blockly-workspace-notes/package.json index 8874369..e8264da 100644 --- a/blockly-workspace-notes/package.json +++ b/blockly-workspace-notes/package.json @@ -1,6 +1,6 @@ { "name": "@mit-app-inventor/blockly-workspace-notes", - "version": "0.4.0", + "version": "0.1.0", "description": "Sticky notes for a Blockly workspace: draggable, resizable, colour-coded comments with a title, that round-trip through JSON and XML.", "scripts": { "audit:fix": "blockly-scripts auditFix", From 271e1c92357fd85a8fa6b43df285bc8527bca4a4 Mon Sep 17 00:00:00 2001 From: Preet Vadaliya Date: Tue, 8 Sep 2026 22:03:54 +0200 Subject: [PATCH 07/20] fix: align note serialization with Blockly's own contract --- .../src/serialization/state.ts | 44 ++++++---- .../src/serialization/xml/dom.ts | 2 +- .../src/types/serialization.ts | 19 ++++- .../test/serializer.mocha.js | 84 +++++++++++++++++++ 4 files changed, 130 insertions(+), 19 deletions(-) diff --git a/blockly-workspace-notes/src/serialization/state.ts b/blockly-workspace-notes/src/serialization/state.ts index dedb193..bb51710 100644 --- a/blockly-workspace-notes/src/serialization/state.ts +++ b/blockly-workspace-notes/src/serialization/state.ts @@ -52,10 +52,12 @@ export function createNote( */ export function saveNote( note: Note | NoteComment, - {addCoordinates = false, saveIds = false} = {}, + {addCoordinates = false, saveIds = true} = {}, ): SavedNote { const workspace = note.workspace; - const state: SavedNote = {} as SavedNote; + // Null-prototype, as core's own serializer builds it: the state is handed + // straight to JSON, and nothing inherited belongs in it. + const state: SavedNote = Object.create(null); state.height = note.getSize().height; state.width = note.getSize().width; @@ -126,24 +128,36 @@ export function appendNote( if (state.text !== undefined) note.setText(state.text); if (state.x !== undefined || state.y !== undefined) { - const rawX = state.x ?? 0; - const x = workspace.RTL ? workspace.getWidth() - rawX : rawX; - note.moveTo(new Blockly.utils.Coordinate(x, state.y ?? 0)); + // A missing coordinate keeps the one the note already has, rather than + // snapping it to the origin. The RTL flip applies only to a value that + // came out of the file — `getRelativeToSurfaceXY` is already in + // workspace coordinates, so flipping it would move the note. + const defaultLoc = note.getRelativeToSurfaceXY(); + const x = + state.x === undefined + ? defaultLoc.x + : workspace.RTL + ? workspace.getWidth() - state.x + : state.x; + note.moveTo(new Blockly.utils.Coordinate(x, state.y ?? defaultLoc.y)); } if (state.width !== undefined || state.height !== undefined) { - note.setSize(new Blockly.utils.Size(state.width ?? 0, state.height ?? 0)); + // Likewise: half a size is not a reason to collapse the other half to + // zero, which would leave a note that is saved but invisible. + const defaultSize = note.getSize(); + note.setSize( + new Blockly.utils.Size( + state.width ?? defaultSize.width, + state.height ?? defaultSize.height, + ), + ); } - if (state.collapsed !== undefined) { - note.setCollapsed(state.collapsed as boolean); - } - if (state.editable !== undefined) - note.setEditable(state.editable as boolean); - if (state.movable !== undefined) note.setMovable(state.movable as boolean); - if (state.deletable !== undefined) { - note.setDeletable(state.deletable as boolean); - } + if (state.collapsed !== undefined) note.setCollapsed(state.collapsed); + if (state.editable !== undefined) note.setEditable(state.editable); + if (state.movable !== undefined) note.setMovable(state.movable); + if (state.deletable !== undefined) note.setDeletable(state.deletable); if (typeof note.setTitle === 'function') { if (state.colour !== undefined) note.setColour(state.colour); diff --git a/blockly-workspace-notes/src/serialization/xml/dom.ts b/blockly-workspace-notes/src/serialization/xml/dom.ts index c2bdca3..2de509e 100644 --- a/blockly-workspace-notes/src/serialization/xml/dom.ts +++ b/blockly-workspace-notes/src/serialization/xml/dom.ts @@ -51,7 +51,7 @@ export function decorateElement(elem: Element, state: SavedNote): void { * @returns The note state. */ export function domToNoteState(elem: Element): SavedNote { - const state: SavedNote = {} as SavedNote; + const state: SavedNote = Object.create(null); const id = elem.getAttribute('id'); if (id) state.id = id; diff --git a/blockly-workspace-notes/src/types/serialization.ts b/blockly-workspace-notes/src/types/serialization.ts index 400c1a7..a9d84bd 100644 --- a/blockly-workspace-notes/src/types/serialization.ts +++ b/blockly-workspace-notes/src/types/serialization.ts @@ -17,17 +17,30 @@ import type {NoteMeta} from './note'; * handful of keys. */ export interface SavedNote { - width: number; - height: number; + // Geometry and the fields core's own comment format carries. Every one is + // optional, matching `Blockly.serialization.workspaceComments.State`: a + // sparse file may omit any of them, and `appendNote` falls back to the + // note's own defaults rather than assuming a value is present. + id?: string; x?: number; y?: number; - id?: string; + width?: number; + height?: number; text?: string; + collapsed?: boolean; + editable?: boolean; + movable?: boolean; + deletable?: boolean; + + // What a note adds. title?: string; colour?: string; pinned?: boolean; zIndex?: number; meta?: Partial; + + // Unknown keys are preserved rather than rejected, so a file written by a + // newer version of the plugin survives a load and re-save by an older one. [key: string]: unknown; } diff --git a/blockly-workspace-notes/test/serializer.mocha.js b/blockly-workspace-notes/test/serializer.mocha.js index bd84b22..7e7a0e3 100644 --- a/blockly-workspace-notes/test/serializer.mocha.js +++ b/blockly-workspace-notes/test/serializer.mocha.js @@ -12,6 +12,7 @@ import { NoteComment, SCHEMA_VERSION, WorkspaceNotes, + appendNote, isNote, migrate, saveNote, @@ -314,6 +315,89 @@ suite('Note serialization', function () { }); }); + suite('partial state', function () { + // A sparse file is the whole point of the format, so loading one must + // never invent a value. Core falls back to the object's own geometry; + // falling back to zero would leave a note that is saved but invisible. + test('a missing height keeps the note’s own height', function () { + const note = appendNote({width: 300}, this.workspace); + assert.equal(note.getSize().width, 300); + assert.isAbove(note.getSize().height, 0, 'height must not collapse'); + }); + + test('a missing width keeps the note’s own width', function () { + const note = appendNote({height: 90}, this.workspace); + assert.equal(note.getSize().height, 90); + assert.isAbove(note.getSize().width, 0, 'width must not collapse'); + }); + + test('an absent coordinate is not RTL-flipped', function () { + // The flip converts a saved x into a workspace x, so it only applies to + // a value the file actually carried. Applying it to the fallback sent + // the note to the far edge of an RTL workspace instead of leaving it + // where it was. + const rtl = new Blockly.Workspace(new Blockly.Options({rtl: true})); + // Headless workspaces report a width of 0, which hides the arithmetic. + rtl.getWidth = () => 500; + try { + const note = appendNote({y: 20}, rtl); + const loc = note.getRelativeToSurfaceXY(); + assert.equal(loc.x, 0, 'x should not have moved'); + assert.equal(loc.y, 20); + } finally { + rtl.dispose(); + } + }); + + test('a coordinate that is present is still RTL-flipped', function () { + const rtl = new Blockly.Workspace(new Blockly.Options({rtl: true})); + rtl.getWidth = () => 500; + try { + const note = appendNote({x: 120, y: 20}, rtl); + assert.equal(note.getRelativeToSurfaceXY().x, 380); + } finally { + rtl.dispose(); + } + }); + + test('no geometry at all leaves the defaults untouched', function () { + const note = appendNote({title: 'Bare'}, this.workspace); + assert.equal(note.getTitle(), 'Bare'); + assert.isAbove(note.getSize().width, 0); + assert.isAbove(note.getSize().height, 0); + }); + + test('an unknown key is ignored rather than throwing', function () { + const note = appendNote( + {width: 200, height: 120, somethingNewer: 42}, + this.workspace, + ); + assert.isTrue(isNote(note)); + }); + }); + + suite('saveNote defaults', function () { + // The signature mirrors `Blockly.serialization.workspaceComments.save`, + // whose `saveIds` defaults to true. A different default here would lose + // ids for anyone porting a call across. + test('ids are written unless asked otherwise', function () { + const note = makeNote(this.workspace); + assert.equal(saveNote(note).id, note.id); + assert.isUndefined(saveNote(note, {saveIds: false}).id); + }); + + test('coordinates are omitted unless asked for', function () { + const note = makeNote(this.workspace, {x: 10, y: 20}); + assert.isUndefined(saveNote(note).x); + assert.equal(saveNote(note, {addCoordinates: true}).x, 10); + }); + + test('the state carries no inherited properties', function () { + const state = saveNote(makeNote(this.workspace)); + assert.isNull(Object.getPrototypeOf(state)); + }); + }); + suite('clear', function () { test('loading disposes the notes already present', function () { makeNote(this.workspace, {text: 'gone'}); From e9eaa8de98a86f6d03dd85b92467b4b3798b3feb Mon Sep 17 00:00:00 2001 From: Preet Vadaliya Date: Tue, 8 Sep 2026 22:26:38 +0200 Subject: [PATCH 08/20] docs: split the documentation into docs/ and redraw the diagrams --- blockly-workspace-notes/DESIGN.md | 440 ------------------ blockly-workspace-notes/README.md | 80 +--- blockly-workspace-notes/docs/README.md | 11 + blockly-workspace-notes/docs/api.md | 73 +++ blockly-workspace-notes/docs/design.md | 128 +++++ .../docs/getting-started.md | 77 +++ .../docs/images/note-anatomy.svg | 88 ++-- .../docs/images/note-states.svg | 96 ++-- blockly-workspace-notes/docs/saving.md | 99 ++++ blockly-workspace-notes/docs/using-notes.md | 94 ++++ blockly-workspace-notes/package.json | 2 +- 11 files changed, 591 insertions(+), 597 deletions(-) delete mode 100644 blockly-workspace-notes/DESIGN.md create mode 100644 blockly-workspace-notes/docs/README.md create mode 100644 blockly-workspace-notes/docs/api.md create mode 100644 blockly-workspace-notes/docs/design.md create mode 100644 blockly-workspace-notes/docs/getting-started.md create mode 100644 blockly-workspace-notes/docs/saving.md create mode 100644 blockly-workspace-notes/docs/using-notes.md diff --git a/blockly-workspace-notes/DESIGN.md b/blockly-workspace-notes/DESIGN.md deleted file mode 100644 index 6029ed5..0000000 --- a/blockly-workspace-notes/DESIGN.md +++ /dev/null @@ -1,440 +0,0 @@ -# 📝 Workspace Notes — Design Document - -**Product:** Sticky notes for a Blockly workspace -**Status:** Built and working -**Last updated:** 7 September 2026 - ---- - -## 1. đŸŽ¯ The problem - -A Blockly workspace is full of blocks, and blocks only say what the program -does. They cannot say _why_. - -People need somewhere to put the human part: a reminder, a question for a -teammate, a warning about a tricky bit, a to-do list for later. Today there is -nowhere good to put that. - -**Workspace Notes** adds sticky notes you can place anywhere on the canvas. - -The single most important requirement: **a note must survive a save and -reload.** A note that disappears when you close the tab is worse than useless, -because people will trust it and then lose work. - ---- - -## 2. 👤 Who it is for - -| Person | What they need | How notes help | -| ----------------- | --------------------------------------- | ---------------------------------------------------- | -| 🎓 **A teacher** | Leave instructions on a starter project | Pin a bright note at the top with the task | -| 🧑‍🎓 **A student** | Remember what they were doing | Drop a note next to the half-finished part | -| đŸ‘Ĩ **A team** | Explain a decision to each other | A titled, colour-coded note beside the tricky blocks | -| 🧑‍đŸ’ģ **A reviewer** | Flag things without changing code | A red-ish note saying "this loop looks wrong" | - ---- - -## 3. ✅ Goals and đŸšĢ Non-goals - -### Goals - -- ✅ Notes save and load with the workspace, every time, losing nothing. -- ✅ Notes feel like part of Blockly, not something bolted on. -- ✅ Notes are quick to create and quick to get out of the way. -- ✅ A note can be told apart at a glance — by colour and by title. -- ✅ Everything is undoable. Nothing is lost by accident. -- ✅ Old files that had plain comments still open, and keep working. - -### Non-goals - -- đŸšĢ **Rich text.** No bold, links, or images inside a note. Plain text only. -- đŸšĢ **Comment threads.** A note is not a discussion. No replies, no mentions. -- đŸšĢ **Live collaboration.** Two people editing the same note at once is out of - scope. -- đŸšĢ **Notes tied to a block.** A note lives on the canvas, not attached to a - block that might move or be deleted. -- đŸšĢ **Notes fixed to the screen.** A note lives on the canvas and scrolls with - it. See the decision in section 9. - ---- - -## 4. 🧭 Scope: what is new, and what we get for free - -Blockly already has "workspace comments" — a plain box you can type into. It -turns out they already do a lot. - -| Capability | Already in Blockly | New in this design | -| ------------------------------------------------- | ------------------ | ------------------ | -| A box on the canvas you can type into | ✅ | | -| Drag to move | ✅ | | -| Drag a corner to resize | ✅ | | -| Collapse and expand | ✅ | | -| Delete | ✅ | | -| Copy, paste, duplicate | ✅ | | -| Keyboard navigation and screen-reader labels | ✅ | | -| Undo and redo | ✅ | | -| A **title** | | ✨ New | -| A **colour** per note | | ✨ New | -| **Pinning** — lock a note in place | | ✨ New | -| **Stacking order** — bring to front, send to back | | ✨ New | -| **Author and dates** recorded automatically | | ✨ New | -| A **versioned save format** for all of the above | | ✨ New | - -> 💡 **The design decision behind this table:** we build _on top of_ Blockly's -> comment rather than replacing it. Everything in the left column keeps working -> exactly as people already expect, and the new work is only the right column. - ---- - -## 5. đŸ–ŧī¸ What a note looks like - -### The parts of a note - -![Anatomy of a note](docs/images/note-anatomy.svg) - -A note is one sheet of paper: - -- **The title row** — a heading printed on the paper itself, set a size above - the body and in bold, because a heading at body size is not a heading. No - strip, no buttons; the title is the only thing there. -- **A hairline rule** under it, dividing the heading from the body. -- **The body** — the text, written straight onto the paper. No box around it. - -### The states a note can be in - -![Note states](docs/images/note-states.svg) - -The top-left one is a plain Blockly comment, shown for comparison. Square -corners, a darker header strip, text in a bordered box. Both halves of that are -block grammar: a rectangle with a strip across the top is a block's silhouette, -and a lighter bordered box inset into a coloured body is how a field on a block -is drawn. A note drops both — a rounded card, a heading, a rule, and the text on -the paper. - -### Visual rules - -| Rule | Why | -| ------------------------------- | ----------------------------------------------------------------------------------------------------------------- | -| 📐 Even spacing on every side | Uneven gaps look accidental. Every margin around an icon or the text is the same. | -| 🎨 Edge colour follows the fill | Each colour gets a matching, slightly deeper edge, so notes look like a set rather than seven unrelated stickers. | -| 🔤 Title always readable | The title is dark, bold and a size larger than the body on every colour in the palette. | -| đŸ¤Ģ No buttons at all | Every action is in the context menu, so nothing sits on the paper but the words on it. | -| âœ‚ī¸ Long titles shorten | A title too long for the note is cut with an ellipsis, and re-cut live while the note is resized. | - ---- - -## 6. ✨ The features - -### 6.1 Create a note - -Right-click anywhere on empty canvas and choose **Add Comment**. The note -appears where you clicked, ready to type in. - -### 6.2 Write in it - -Click the body and type. An empty note shows faded placeholder text so it never -looks broken. Text is saved as you go. - -### 6.3 Move and resize - -Drag the note by its title row to move it. Drag the bottom-right corner to -resize; the title re-shortens as you go, so it never runs off the paper. The -drag stops at a floor of one title row, one line of body and the margin under -it, so a note can always be seen and grabbed — dragging one down to nothing -would leave a note that is still on the workspace and still saved, findable -only by undo. - -### 6.4 Give it a title đŸˇī¸ - -Click the title and type — the same as clicking into the body below it. -Nothing opens and nothing moves; the heading just gains a caret. Enter commits, -Escape puts the old title back, and clicking elsewhere commits. A note that has -not been named shows a greyed `Title`, which stays put while you type over it. - -Dragging a note by its title still drags it — the editor only opens if the -press stayed put, which is the same test Blockly uses to tell a field click -from a drag. - -The title is the thing you read when you are scanning a busy workspace, so it -stays visible even when the note is collapsed down to a single strip. - -### 6.5 Colour it 🎨 - -Right-click the note and pick from a row of seven colours: yellow, orange, -pink, purple, blue, green, grey. - -The swatches sit in a single row inside the menu rather than as seven separate -menu entries, so choosing a colour is one click and the menu stays short. The -current colour is ringed so you can see what the note is now. - -Colours are pale on purpose. A note has to be readable, and it must not shout -louder than the blocks around it. - -### 6.6 Pin it 📌 - -Right-click → **Pin note**. A pinned note: - -- 🔒 **Cannot be dragged.** It stays exactly where you put it. -- âŦ†ī¸ **Sits in front** of other notes. -- âœī¸ **Draws a heavier edge**, so you can see why it will not move. - -Right-click → **Unpin note** to release it. This is for the note that must not -be nudged out of the way — a teacher's instructions, a warning at the top of a -file. - -### 6.7 Order them đŸ”ŧ - -Right-click → **Bring to front** or **Send to back**. Useful when notes overlap. -The order is remembered when you save. - -### 6.8 Collapse it đŸ”Ŋ - -Right-click → **Collapse note** to fold a note down to just its title row. The -title stays visible. Right-click → **Expand note** to open it again. - -This is how you keep a long note around without it covering your blocks. - -### 6.9 Duplicate it 📋 - -Right-click → **Duplicate note**, or copy and paste. The copy keeps the title, the -colour, and the pinned state, and lands slightly offset so it does not hide the -original. - -### 6.10 Delete it, and undo đŸ—‘ī¸ â†Šī¸ - -Right-click → **Delete note**, or press Delete while it is selected. - -**One press of undo brings the note back complete** — same text, same title, -same colour, same pinned state. Not a blank note that you then have to -re-decorate. This mattered enough to design for specifically. - -Every change is undoable: typing, resizing, recolouring, renaming, pinning, -reordering. - ---- - -## 7. 💾 What gets remembered - -This is the headline feature, so it is worth being explicit about it. - -When a workspace is saved, every note is saved alongside the blocks. Nothing -extra to do, nothing separate to call. - -| Remembered | Notes | -| ------------------------------------- | --------------------------------------------------- | -| 📍 Position on the canvas | Exact, including right-to-left layouts | -| 📏 Width and height | Exactly as the user left it | -| 📝 The text | | -| đŸˇī¸ The title | Only if it has one | -| 🎨 The colour | Only if it is not the default | -| 📌 Pinned or not | | -| đŸ”ŧ Stacking order | So overlapping notes come back in the same order | -| đŸ”Ŋ Collapsed or expanded | | -| 👤 Author | Whoever created it, if the host app supplies a name | -| 🕐 Created date and last-changed date | Set automatically | - -A saved note looks like this: - - { - "id": "n1qX", - "x": 40, "y": 20, - "width": 240, "height": 140, - "text": "Refactor this loop", - "title": "TODO", - "colour": "#ffd6a5", - "meta": { - "author": "ada", - "createdAt": "2026-09-07T16:07:09Z", - "updatedAt": "2026-09-07T16:09:41Z" - } - } - -### Three rules about the format - -**đŸ“Ļ Only write what is different.** A plain yellow note with no title records -no colour and no title. Files stay small, and a change to one note shows up as -a small, readable difference rather than a wall of text. - -**đŸ”ĸ Always record a version number.** The saved data carries a version. If the -format ever gains a field or changes shape, files saved today can still be -opened tomorrow — they are quietly upgraded as they load. This costs almost -nothing now and avoids a painful migration later. - -**đŸ•°ī¸ Old files still open.** Workspaces saved before this feature existed, with -plain Blockly comments in them, load correctly. Each old comment becomes a note -with default colour and no title. Nothing is lost, and nothing needs converting -by hand. - ---- - -## 8. 🔄 Behaviour rules - -These are the small decisions that make the feature feel finished. - -| Situation | What happens | -| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| A note is saved and the file is loaded twice | You get the same notes back. Never duplicates. | -| A note is pinned, then you try to drag it | Nothing moves. The heavier edge explains why. | -| A note is pinned, then unpinned | It becomes draggable again. | -| A title is longer than the note is wide | It is shortened with an ellipsis, live, as the note is resized. | -| The writing is longer than the note is tall | The body scrolls. The bar is thin, trackless and in the paper's own edge colour, painted only while the pointer is on the note or the caret is in it. | -| A note is resized down as far as it will go | It stops at its minimum, still readable and still grabbable. It cannot be hidden. | -| A note is collapsed and has a title | The title shows. | -| A note is collapsed and has no title | The greyed `Title` placeholder shows, as it does when expanded. | -| A note is deleted and undone | It returns complete, in one undo. | -| A note is duplicated | The copy keeps everything and is offset so both are visible. | -| The workspace is read-only | Notes can be read and moved through, but not edited. | -| A file is loaded | Loading does not fill up the undo history. Undo still means "undo what _I_ did". | - ---- - -## 9. âš–ī¸ Design decisions - -### Why extend Blockly's comment instead of building a new object - -Blockly's comment already handles dragging, resizing, keyboard access, screen -readers, copy and paste, and undo. Rebuilding all of that would have been a -large amount of work whose only outcome is re-creating bugs that are already -fixed. - -**Trade-off:** the design is tied to how Blockly's comments behave. If Blockly -changes them, this feature has to follow. - -### Why "pinned" means locked, not fixed to the screen - -"Pinned" could mean two things: - -1. 🔒 **Locked in place** — it stays where you put it on the canvas. -2. 📌 **Fixed to the screen** — it never scrolls away, like a heads-up display. - -We chose **locked in place**. The screen-fixed version fights the way a -workspace scrolls and zooms, and a note that floats over your blocks wherever -you pan is more annoying than helpful. - -**Open question:** if the screen-fixed behaviour turns out to be what teachers -actually want for instructions, that is a change of behaviour rather than a -bug, and worth revisiting. - -### Why the title is edited in place rather than in a dialog - -An earlier version asked for the title in a dialog, which was the wrong -instinct twice over: Blockly never asks for text in a dialog, and the fallback -`window.prompt` is a serif system box that matches nothing else on the page. -Clicking the title and typing is what a field on a block already does, so it is -the behaviour people arrive with. - -Replacing it with Blockly's field editor was only half the fix, though, because -a field editor is built to be seen — a white box with the text selected. On a -note that still read as a mode opening on the paper. The editor is now -invisible: same position, same font, same placeholder, no box, no selection. -The rule is that the title should behave exactly like the body, and the body -has never needed anything to open. - -**Trade-off:** a click on the title now does two things depending on whether it -moved. Blockly's own gesture code draws that line at the drag radius, and this -follows it, so dragging a note by its title still works. - -### Why a note has no buttons - -Every earlier round put icons in the top bar — collapse, delete, pin — and every -round the note read as a block, because a strip of icons across the top of a -rectangle is exactly what a block looks like. Moving all of it into the context -menu leaves nothing on the paper but the words on it, and the menu is where -people look for actions on a right-clickable object anyway. - -**Trade-off:** collapsing and pinning are one click further away, and slightly -less discoverable. Pinning was already menu-only; collapsing is the real cost. - -### Why notes get their own place in the save file - -Notes are saved separately from Blockly's plain comments rather than pretending -to be them. This keeps the extra information clean and versioned, and means a -note is never saved twice by accident. - -**Trade-off:** a file saved with notes is not a plain Blockly file. Anything -reading it needs to know about notes — though a plain Blockly editor would -simply ignore them rather than break. - -### Why colours are pale - -Notes sit among coloured blocks. A saturated note would compete with them and -make the workspace harder to read. Pale fills with a slightly deeper edge read -as "paper on a desk" rather than "another block". - -Colour alone was not enough, though — several rounds of tuning hue, icon colour -and text colour all failed, because what reads as a block is the shape. The -palette was pulled paler again once the shape was fixed, so the two work -together: S 0.25 at V 0.98, against a block's S 0.45 at V 0.65. - ---- - -## 10. đŸ—‚ī¸ How the code is laid out - -Each folder is one layer, and a file is named for the single thing it holds. -Reading top to bottom is roughly reading the plugin's dependency order. - -``` -src/ -├── index.ts The whole public surface. Re-exports only, no logic. -├── plugin.ts WorkspaceNotes: what a host constructs. -├── model/ What a note is. note_mixin applies to both classes. -├── serialization/ JSON in and out, plus xml/ for the older format. -├── events/ The undo event a note fires. -├── clipboard/ Pasting a note with its title and colour intact. -├── ui/ Chrome the user touches: menu, title editor, CSS. -├── utils/ Pure helpers. No registration, no module state. -├── constants/ Numbers and names, grouped by what they configure. -└── types/ The shapes that travel between the layers. -``` - -Four rules keep it that way: - -- **`index.ts` holds no logic.** Anything it does not re-export is internal and - free to move. It is also the build entry point, resolved by path, so it stays - where it is. -- **No barrel files inside the folders.** Every import names the file it wants - (`../constants/layout`, never `../constants`). Longer to type, and the reason - the import graph stays legible as the code grows. -- **`utils/` is inert.** Pure functions, no Blockly registration, no - module-level state — importable from a test with nothing set up. -- **Registration lives in `registry.ts`.** Anything that mutates a global - Blockly registry does it in a file named `registry.ts` (or `xml/patch.ts`), - so the side effects are findable in one sweep and every one of them has a - matching `unregister`. - -One import cycle exists on purpose: `model/note.ts` and `model/stacking.ts` -need each other, since a note restacks its neighbours when its z-index changes -and restacking needs the class to recognise a note. Both uses sit inside -function bodies, so neither runs while the modules are still evaluating. - ---- - -## 11. 🚧 Limits and future work - -### Known limits - -- 📄 **Plain text only.** No formatting, links or images in a note. -- 🎨 **Seven colours.** No custom colour picker. -- 🔍 **Not searchable.** There is no way to find a note by its text yet. -- 🔗 **Not attached to blocks.** A note near a block is only near it by - position. Move the block and the note stays put. - -### Ideas worth considering next - -| Idea | Why it might matter | -| ------------------------------- | ----------------------------------------------------------- | -| 🔍 **Search notes** | Once a workspace has twenty notes, finding one is hard. | -| 🔗 **Attach a note to a block** | The most-requested thing this design deliberately left out. | -| ✅ **Checklists** | Teachers writing task lists would use them immediately. | -| đŸˇī¸ **Colour meanings** | Let a project define "red = bug, green = done". | -| 👤 **Show the author** | The name is already recorded but never displayed. | - ---- - -## 12. ❓ Open questions - -1. Should pinned notes stay fixed on screen instead of on the canvas? - (See section 9.) -2. Should the author's name be visible on the note, or stay hidden data? -3. Are seven colours enough, or is a custom colour needed? -4. Should a note be able to point at a block, without being owned by it? diff --git a/blockly-workspace-notes/README.md b/blockly-workspace-notes/README.md index cfdcc3c..d9e4301 100644 --- a/blockly-workspace-notes/README.md +++ b/blockly-workspace-notes/README.md @@ -6,7 +6,7 @@ Sticky notes for a Blockly workspace: draggable, resizable, colour-coded paper with a title, an author and a stacking order.

- The parts of a note + The parts of a note: title, rule, card, body and resize handle

Notes extend Blockly's own workspace comments, so dragging, resizing, @@ -31,76 +31,21 @@ const workspace = Blockly.inject('blocklyDiv', {toolbox}); new WorkspaceNotes(workspace).init(); ``` -> [!IMPORTANT] -> Import the plugin **before** calling `Blockly.inject`. `Blockly.Css.register` -> only affects injections that happen after it runs, so a note imported later -> renders unstyled. - That is the whole setup. Right-click the workspace to add a note; click a note's title to rename it; right-click a note to recolour, pin, collapse or -restack it. Everything else — moving, resizing, copying, deleting, undo — is -Blockly's own behaviour. - -## Options - -Pass an options object as the second argument. - -| Option | Default | What it does | -| ---------------------------- | -------------------- | -------------------------------------------------------- | -| `palette` | 7 stationery colours | The swatches offered in the Colour menu | -| `defaultSize` | `260 × 180` | Size a new note is created at | -| `getAuthor` | `() => ''` | Called once per note to record who made it | -| `contextMenu` | `true` | Register the note menu items | -| `xmlSupport` | `true` | Wrap `Blockly.Xml` so notes survive the older XML format | -| `skipSerializerRegistration` | `false` | Leave persistence entirely to your app | -| `emitLegacyComments` | `false` | Also write the old `workspaceComments` key | +restack it. -```js -new WorkspaceNotes(workspace, { - defaultSize: {width: 300, height: 200}, - getAuthor: () => currentUser.name, -}).init(); -``` +> **Import the plugin before `Blockly.inject`.** Blockly's stylesheets only +> reach workspaces injected after they are registered. -## API - -| Member | What it does | -| -------------------- | --------------------------------------------------- | -| `init()` | Starts the plugin. Idempotent. | -| `dispose()` | Stops it and restores everything Blockly had before | -| `createNote(state?)` | Adds a note, in front, as one undo step | -| `getNotes()` | Every note on the workspace | - -The note classes, serializers and helpers are exported too, for an app that -drives saving and loading itself. - -## Saved format - -Notes are saved under their own versioned `workspaceNotes` key, beside -`blocks`. Older files written under `workspaceComments` still load, as notes. - -```json -{ - "workspaceNotes": { - "version": 1, - "notes": [ - { - "id": "n1qX", - "x": 40, - "y": 20, - "width": 240, - "height": 140, - "text": "Refactor this loop", - "title": "TODO", - "colour": "#ffd6a5" - } - ] - } -} -``` +## Documentation -Only what differs from the default is written, so a plain yellow note with no -title records neither. +- [Getting started](./docs/getting-started.md) — install, setup and options +- [Using notes](./docs/using-notes.md) — everything you can do with a note +- [API](./docs/api.md) — the exported classes and functions +- [Saving and loading](./docs/saving.md) — the save format, JSON and XML +- [Design](./docs/design.md) — why it works this way, and how the source is + laid out ## Develop @@ -120,9 +65,6 @@ npm run lint # ESLint npm run format # Prettier ``` -[DESIGN.md](./DESIGN.md) covers why a note works the way it does, and how the -source is laid out. - ## License Apache-2.0 diff --git a/blockly-workspace-notes/docs/README.md b/blockly-workspace-notes/docs/README.md new file mode 100644 index 0000000..b2d11ca --- /dev/null +++ b/blockly-workspace-notes/docs/README.md @@ -0,0 +1,11 @@ +# Documentation + +- **[Getting started](./getting-started.md)** — install it, switch it on, and + the options you can pass. +- **[Using notes](./using-notes.md)** — what a note is and everything you can + do with one. +- **[API](./api.md)** — the classes and functions the package exports. +- **[Saving and loading](./saving.md)** — what a saved note looks like, in JSON + and in XML. +- **[Design](./design.md)** — why a note works the way it does, and how the + source is laid out. diff --git a/blockly-workspace-notes/docs/api.md b/blockly-workspace-notes/docs/api.md new file mode 100644 index 0000000..92dce07 --- /dev/null +++ b/blockly-workspace-notes/docs/api.md @@ -0,0 +1,73 @@ +# API + +Most apps only need `WorkspaceNotes`. The rest is here for apps that drive +saving and loading themselves. + +## WorkspaceNotes + +```js +const notes = new WorkspaceNotes(workspace, options); +``` + +| Member | What it does | +| -------------------- | --------------------------------------------------- | +| `init()` | Starts the plugin. Safe to call twice. | +| `dispose()` | Stops it and restores everything Blockly had before | +| `createNote(state?)` | Adds a note, in front, as one undo step | +| `getNotes()` | Every note on the workspace | + +```js +notes.createNote({title: 'TODO', text: 'Check the loop', colour: '#f9bbc5'}); +notes.getNotes().filter((note) => note.isPinned()); +``` + +## A note + +`Note` is a note on a rendered workspace; `NoteComment` is the same thing +headless, which is what you get on a workspace with no renderer. Both carry the +same note-specific methods on top of Blockly's own comment API. + +| Method | What it does | +| ------------------------------ | --------------------------------------- | +| `getTitle()` / `setTitle(s)` | The heading | +| `getColour()` / `setColour(c)` | Any CSS colour; stored as hex | +| `isPinned()` / `setPinned(b)` | Locked in place and raised to the front | +| `getZIndex()` / `setZIndex(n)` | Stacking order; higher is nearer front | +| `getMeta()` | `{author, createdAt, updatedAt}` | +| `saveNoteState()` | A plain copy of all of the above | + +Everything else — `getText`, `setText`, `moveTo`, `setSize`, `setCollapsed`, +`dispose` — is Blockly's, and documented there. + +## Helpers + +| Function | What it does | +| ------------------------- | ------------------------------------------------- | +| `isNote(x)` | Whether a workspace comment is one of ours | +| `restackNotes(workspace)` | Reorders notes on screen to match their z-indices | +| `saveNote(note, opts?)` | One note to JSON | +| `appendNote(state, ws)` | JSON back to a note on the workspace | +| `migrate(payload)` | Upgrades an older payload to the current version | +| `noteToDom(note)` | One note to a `` element | +| `domToNoteState(elem)` | A `` element to JSON | +| `domToNote(elem, ws)` | A `` element to a note on the workspace | + +```js +import {isNote, saveNote} from '@mit-app-inventor/blockly-workspace-notes'; + +const notes = workspace.getTopComments(false).filter(isNote); +const json = notes.map((note) => saveNote(note, {addCoordinates: true})); +``` + +## Types + +`SavedNote`, `NotesPayload`, `NoteState`, `NoteMeta`, `PaletteEntry`, +`WorkspaceNotesOptions`, `NoteCopyData`, `NoteChangeJson`, `NoteProperty`, +`NotePropertyValue` and `NoteSurface` are all exported for TypeScript users. + +## For extending the plugin + +`NoteSerializer`, `LegacyCommentAdapter`, `NotePaster` and `NoteChange` are +exported so you can subclass or inspect them. `NOTE_SERIALIZER_NAME`, +`SCHEMA_VERSION`, `DEFAULT_COLOUR` and `DEFAULT_PALETTE` are the constants +worth reading. diff --git a/blockly-workspace-notes/docs/design.md b/blockly-workspace-notes/docs/design.md new file mode 100644 index 0000000..e17a554 --- /dev/null +++ b/blockly-workspace-notes/docs/design.md @@ -0,0 +1,128 @@ +# Design + +A Blockly workspace is full of blocks, and blocks only say what the program +does. They cannot say why. Notes are somewhere to put the human part: a +reminder, a question for a teammate, a warning about a tricky bit. + +The requirement everything else follows from: **a note must survive a save and +reload.** A note that disappears when you close the tab is worse than useless, +because people will trust it and then lose work. + +## What is new, and what comes for free + +Blockly already has workspace comments — a plain box you can type into. They +already handle a great deal: + +| Already in Blockly | New here | +| -------------------------------------------- | ----------------------- | +| A box you can type into | A title | +| Drag to move, drag a corner to resize | A colour per note | +| Collapse, delete, copy and paste | Pinning — lock in place | +| Keyboard navigation and screen-reader labels | Stacking order | +| Undo and redo | Author and dates | +| | A versioned save format | + +Everything in the left column keeps working exactly as people already expect. + +## The decisions worth explaining + +### Why extend Blockly's comment rather than build a new object + +Dragging, resizing, keyboard access, screen readers, copy and paste, and undo +all already work. Rebuilding them would mean re-creating bugs that are already +fixed. + +The cost is that the design is tied to how Blockly's comments behave. If +Blockly changes them, this has to follow. + +### Why a note has no buttons + +Every early round put icons in the title row — collapse, delete, pin — and +every round the note read as a _block_, because a strip of icons across the top +of a rectangle is exactly what a block looks like. Moving all of it into the +right-click menu leaves nothing on the paper but the words on it. + +The cost is that collapsing is one click further away and slightly less +discoverable. + +### Why the title is edited in place + +An earlier version asked for the title in a dialog. That was wrong twice over: +Blockly never asks for text in a dialog, and the fallback is the browser's own +serif prompt box, which matches nothing else on the page. + +Using Blockly's field editor was only half the fix, because a field editor is +built to be _seen_ — a white box with the text selected — and on a note that +still read as a mode opening on the paper. The editor is now invisible: same +position, same font, same placeholder, no box, no selection. The title should +behave exactly like the body, and the body has never needed anything to open. + +The cost is that a click on the title does two things depending on whether it +moved. Blockly's own gesture code draws that line at the drag radius, and this +follows it, so dragging a note by its title still works. + +### Why "pinned" means locked, not fixed to the screen + +Pinned could mean locked to the canvas, or fixed to the screen like a +heads-up display. Locked won: a note that floats over your blocks wherever you +pan is more annoying than helpful, and the screen-fixed version fights the way +a workspace scrolls and zooms. + +### Why colours are pale + +Notes sit among coloured blocks. A saturated note would compete with them and +make the workspace harder to read. + +Colour alone was not enough, though. Several rounds of tuning hue, icon colour +and text colour all failed, because what reads as a block is the _shape_. Once +the shape was fixed — rounded card, no header strip, no boxed text — the +palette could be pulled paler still. The two work together: a note is S 0.25 at +V 0.98, against a block's S 0.45 at V 0.65. + +### Why notes get their own place in the save file + +Notes are saved under their own key rather than pretending to be plain +comments. That keeps the extra fields clean and versioned, and means a note is +never saved twice by accident. + +The cost is that a file with notes in it is not a plain Blockly file — though a +plain Blockly editor ignores them rather than breaking. + +## How the code is laid out + +Each folder is one layer, and a file is named for the single thing it holds. +Reading top to bottom is roughly the dependency order. + +``` +src/ +├── index.ts The whole public surface. Re-exports only, no logic. +├── plugin.ts WorkspaceNotes: what a host constructs. +├── model/ What a note is. +├── serialization/ JSON in and out, plus xml/ for the older format. +├── events/ The undo event a note fires. +├── clipboard/ Pasting a note with its title and colour intact. +├── ui/ Chrome the user touches: menu, title editor, CSS. +├── utils/ Pure helpers. No registration, no module state. +├── constants/ Numbers and names, grouped by what they configure. +└── types/ The shapes that travel between the layers. +``` + +Four rules keep it that way: + +- **`index.ts` holds no logic.** Anything it does not re-export is internal and + free to move. It is also the build entry point, resolved by path, so it stays + where it is. +- **No barrel files inside the folders.** Every import names the file it wants + (`../constants/layout`, never `../constants`). Longer to type, and the reason + the import graph stays legible as the code grows. +- **`utils/` is inert.** Pure functions, no Blockly registration, no + module-level state — importable from a test with nothing set up. +- **Registration lives in `registry.ts`.** Anything that mutates a global + Blockly registry does it in a file named `registry.ts` (or `xml/patch.ts`), + so the side effects are findable in one sweep and every one has a matching + `unregister`. + +One import cycle exists on purpose: `model/note.ts` and `model/stacking.ts` +need each other, since a note restacks its neighbours when its z-index changes +and restacking needs the class to recognise a note. Both uses sit inside +function bodies, so neither runs while the modules are still evaluating. diff --git a/blockly-workspace-notes/docs/getting-started.md b/blockly-workspace-notes/docs/getting-started.md new file mode 100644 index 0000000..351c4ec --- /dev/null +++ b/blockly-workspace-notes/docs/getting-started.md @@ -0,0 +1,77 @@ +# Getting started + +## Install + +```bash +npm install @mit-app-inventor/blockly-workspace-notes +``` + +You need Blockly 13.2.1 or newer. It is a peer dependency, so it stays on your +side of the install. + +## Switch it on + +```js +import * as Blockly from 'blockly'; +import {WorkspaceNotes} from '@mit-app-inventor/blockly-workspace-notes'; + +const workspace = Blockly.inject('blocklyDiv', {toolbox}); +new WorkspaceNotes(workspace).init(); +``` + +That is the whole setup. Right-click the workspace and you will see **Add +Note**. + +> **Import the plugin before `Blockly.inject`.** +> Blockly's stylesheets only reach workspaces injected _after_ they are +> registered, so a note imported later comes out unstyled. + +## Options + +Pass an object as the second argument. Every option has a sensible default, so +pass only what you want to change. + +```js +new WorkspaceNotes(workspace, { + defaultSize: {width: 300, height: 200}, + getAuthor: () => currentUser.name, +}).init(); +``` + +| Option | Default | What it does | +| ---------------------------- | -------------------- | ----------------------------------------------- | +| `palette` | 7 stationery colours | The swatches offered in the Colour menu | +| `defaultSize` | `260 × 180` | The size a new note is created at | +| `getAuthor` | `() => ''` | Called once per note to record who made it | +| `contextMenu` | `true` | Adds the note items to the right-click menu | +| `xmlSupport` | `true` | Keeps notes intact through the older XML format | +| `skipSerializerRegistration` | `false` | Leaves saving and loading entirely to your app | +| `emitLegacyComments` | `false` | Also writes the old `workspaceComments` key | + +### A custom palette + +Each entry needs a name, a hue and the resolved fill colour: + +```js +new WorkspaceNotes(workspace, { + palette: [ + {name: 'Yellow', hue: 48, fill: '#f9edbb'}, + {name: 'Sky', hue: 200, fill: '#bbe5f9'}, + ], +}).init(); +``` + +## Turning it off + +`dispose()` puts Blockly back exactly as it found it — the menu items, the +clipboard, the save format and the default comment size all return to normal. + +```js +const notes = new WorkspaceNotes(workspace); +notes.init(); + +// later +notes.dispose(); +``` + +Both calls are safe to repeat; a second `init()` or `dispose()` does nothing. diff --git a/blockly-workspace-notes/docs/images/note-anatomy.svg b/blockly-workspace-notes/docs/images/note-anatomy.svg index e2df9ca..2248422 100644 --- a/blockly-workspace-notes/docs/images/note-anatomy.svg +++ b/blockly-workspace-notes/docs/images/note-anatomy.svg @@ -1,47 +1,65 @@ - - - - - - - - - - + + The parts of a note + A note at its default size of 260 by 180, with its title, hairline rule, card, body and resize handle labelled. - - Title - click it and type - Rule - equal air above and below - Rounded card - pale fill, hairline edge - The body - written onto the paper, no box - Resize handle - + - - + + + + Shopping list + + + + - milk - bread - coffee + milk + bread + coffee - - - + + + + + - - - - - + + + + + + + + + + + Title + click it and type + + The card + pale fill, hairline edge + + Rule + equal air above and below + + The body + written onto the paper, + with no box around it + + Resize handle + inside the sheet, not on its corner + + + + + + + - margin 16, the same on every side + a margin of 16, the same on every side diff --git a/blockly-workspace-notes/docs/images/note-states.svg b/blockly-workspace-notes/docs/images/note-states.svg index 1b94e28..945d358 100644 --- a/blockly-workspace-notes/docs/images/note-states.svg +++ b/blockly-workspace-notes/docs/images/note-states.svg @@ -1,72 +1,64 @@ - - + + The states a note can be in + Five notes at their default size: a named note, one not named yet, a collapsed one, a pinned one and a selected one. - - A plain Blockly comment - A note - Not named yet - Collapsed - Pinned - Selected - - - header strip, text in a box - rounded, heading on the paper - greyed placeholder - the title row is the whole note - locked in place, heavier edge - the ring follows the card - + - - - - - - - Refactor this loop + + Named + Not named yet + Collapsed + Pinned + Selected + + + the title is what you scan for + greyed placeholders, both rows + the title row is the whole note + locked in place, heavier edge + the ring follows the card - - - + + + Shopping list - + - milk - bread - coffee + milk + bread + coffee - - - + + + Title - - Say something... + + Say something... - - - + + + Release checklist - - - + + + Do not move - - Locked in place. + + Locked in place. - - - - - Ideas - - Try a smaller step. + + + + + Ideas + + Try a smaller step. diff --git a/blockly-workspace-notes/docs/saving.md b/blockly-workspace-notes/docs/saving.md new file mode 100644 index 0000000..9a02bd4 --- /dev/null +++ b/blockly-workspace-notes/docs/saving.md @@ -0,0 +1,99 @@ +# Saving and loading + +Notes save with the workspace. There is nothing extra to call. + +```js +const state = Blockly.serialization.workspaces.save(workspace); +Blockly.serialization.workspaces.load(state, workspace); +``` + +## What a saved note looks like + +Notes get their own key in the file, beside `blocks`: + +```json +{ + "blocks": {"languageVersion": 0, "blocks": []}, + "workspaceNotes": { + "version": 1, + "notes": [ + { + "id": "n1qX", + "x": 40, + "y": 20, + "width": 240, + "height": 140, + "text": "Refactor this loop", + "title": "TODO", + "colour": "#f9bbc5", + "pinned": true, + "zIndex": 3, + "meta": { + "author": "ada", + "createdAt": "2026-09-07T16:07:09Z", + "updatedAt": "2026-09-07T16:09:41Z" + } + } + ] + } +} +``` + +Everything that gets remembered: + +| Saved | Notes | +| --------------------- | -------------------------------------------------- | +| Position | Exact, including right-to-left layouts | +| Width and height | Exactly as you left them | +| The text | | +| The title | Only if it has one | +| The colour | Only if it is not the default | +| Pinned | Only if pinned | +| Stacking order | So overlapping notes come back in the same order | +| Collapsed | Only if collapsed | +| Author and timestamps | Author comes from `getAuthor`; dates are automatic | + +## Three things about the format + +**Only what differs is written.** A plain yellow note with no title records +neither. Files stay small, and a change to one note shows up as a small diff +rather than a wall of text. + +**The payload carries a version.** If the format ever gains a field, files +saved today are quietly upgraded as they load. + +**Old files still open.** A workspace saved before this plugin existed, with +plain Blockly comments in it, loads correctly — each comment becomes a note +with the default colour and no title. + +## XML + +Blockly's older XML format works too, as long as `xmlSupport` is on, which it +is by default. + +```js +const dom = Blockly.Xml.workspaceToDom(workspace); +Blockly.Xml.domToWorkspace(dom, workspace); +``` + +A note is written as a `` element with a few extra attributes: + +```xml +Refactor this loop +``` + +Every added attribute is optional, so plain Blockly still reads the element as +an ordinary comment and ignores what it does not recognise. + +JSON is the format to prefer. Blockly has frozen XML — it still works and is +not going away, but it gains no new features, and only JSON has a proper place +for a plugin's own data. + +## Saving it yourself + +If your app handles persistence its own way, pass +`skipSerializerRegistration: true` and use `saveNote` and `appendNote` +directly. See the [API](./api.md). diff --git a/blockly-workspace-notes/docs/using-notes.md b/blockly-workspace-notes/docs/using-notes.md new file mode 100644 index 0000000..08458ed --- /dev/null +++ b/blockly-workspace-notes/docs/using-notes.md @@ -0,0 +1,94 @@ +# Using notes + +A note is a sheet of paper on the workspace. It has a title you can read at a +glance, a colour, and room to write. + +

+ The parts of a note: title, rule, card, body and resize handle +

+ +Everything a note can do is in the right-click menu. Nothing sits on the paper +except the words on it. + +## Add one + +Right-click empty canvas and choose **Add Note**. It appears where you clicked, +ready to type in. + +## Write in it + +Click the body and type. An empty note shows a faded prompt so it never looks +broken. + +## Name it + +Click the title and type. Nothing opens and nothing moves — the heading just +gains a caret, the same way the body does. + +- **Enter** commits. +- **Escape** puts the old title back. +- Clicking elsewhere commits. + +Dragging a note by its title still moves it. The editor only opens if the press +stayed put. + +## Colour it + +Right-click and pick from a row of seven colours. The current one is ringed. + +The swatches sit in a single row rather than seven separate menu entries, so +picking a colour is one click and the menu stays short. + +## Pin it + +Right-click → **Pin note**. A pinned note cannot be dragged, sits in front of +its neighbours, and draws a heavier edge so you can see why it will not move. + +Right-click → **Unpin note** to release it. + +## Collapse it + +Right-click → **Collapse note** to fold it down to its title row. The title +stays readable. This is how you keep a long note around without it covering +your blocks. + +## Order them + +Right-click → **Bring to front** or **Send to back**, for when notes overlap. +The order is remembered when you save. + +## Move, resize, copy, delete + +These are Blockly's own, and they behave exactly as they do for anything else +on the workspace. + +- Drag the title row to move it. +- Drag the bottom-right corner to resize. The title shortens with an ellipsis + as you go, and the note stops at a size where it can still be read and + grabbed. +- **Duplicate note**, or copy and paste. The copy keeps the title, colour and + pinned state, and lands slightly offset. +- **Delete note**, or press Delete while it is selected. + +One press of undo brings a deleted note back complete — same text, same title, +same colour, same pinned state. Every other change is undoable too: typing, +resizing, recolouring, renaming, pinning, reordering. + +## The states you will see + +

+ A named note, one not named yet, a collapsed note, a pinned note and a selected note +

+ +| State | How you can tell | +| ----------------- | ----------------------------------------------------- | +| **Named** | The title is bold and black on the paper | +| **Not named yet** | A greyed `Title`, and a greyed prompt in the body | +| **Collapsed** | Just the title row. The rule disappears with the body | +| **Pinned** | A heavier edge around the card | +| **Selected** | Blockly's own gold ring, following the card's corners | + +## Keyboard and screen readers + +Notes are workspace comments underneath, so Blockly's keyboard navigation and +screen-reader support apply to them unchanged. Nothing extra to configure. diff --git a/blockly-workspace-notes/package.json b/blockly-workspace-notes/package.json index e8264da..58f7c2a 100644 --- a/blockly-workspace-notes/package.json +++ b/blockly-workspace-notes/package.json @@ -51,7 +51,7 @@ "files": [ "dist", "src", - "LICENSE", + "docs", "README.md" ], "devDependencies": { From 5c04cfc82d7b9df5497c5d96a77a212e5dfad3cd Mon Sep 17 00:00:00 2001 From: Preet Vadaliya Date: Wed, 9 Sep 2026 01:35:45 +0200 Subject: [PATCH 09/20] feat: mark a pinned note with a pin in its title row --- blockly-workspace-notes/docs/design.md | 6 + .../docs/images/note-states.svg | 15 ++- blockly-workspace-notes/docs/using-notes.md | 14 ++- blockly-workspace-notes/src/constants/dom.ts | 3 + .../src/constants/layout.ts | 30 +++++ blockly-workspace-notes/src/model/note.ts | 106 ++++++++++++++++-- .../src/ui/context_menu.ts | 17 +++ blockly-workspace-notes/src/ui/css.ts | 45 +++++++- 8 files changed, 212 insertions(+), 24 deletions(-) diff --git a/blockly-workspace-notes/docs/design.md b/blockly-workspace-notes/docs/design.md index e17a554..eeaa27c 100644 --- a/blockly-workspace-notes/docs/design.md +++ b/blockly-workspace-notes/docs/design.md @@ -45,6 +45,12 @@ right-click menu leaves nothing on the paper but the words on it. The cost is that collapsing is one click further away and slightly less discoverable. +The one mark that did earn a place is the pin, and the distinction is that it +is not a control. It does nothing when clicked, it appears only while a note is +pinned, and it answers a question the note could not otherwise answer: why will +this one not move? A strip of buttons that is always there reads as a block; a +single mark that is usually absent reads as punctuation. + ### Why the title is edited in place An earlier version asked for the title in a dialog. That was wrong twice over: diff --git a/blockly-workspace-notes/docs/images/note-states.svg b/blockly-workspace-notes/docs/images/note-states.svg index 945d358..61a0b0d 100644 --- a/blockly-workspace-notes/docs/images/note-states.svg +++ b/blockly-workspace-notes/docs/images/note-states.svg @@ -1,6 +1,6 @@ The states a note can be in - Five notes at their default size: a named note, one not named yet, a collapsed one, a pinned one and a selected one. + Five notes at their default size: a named note, one not named yet, a collapsed one, a pinned one showing its pin marker, and a selected one. @@ -15,7 +15,7 @@ the title is what you scan for greyed placeholders, both rows the title row is the whole note - locked in place, heavier edge + a pin before the title, and a heavier edge the ring follows the card @@ -48,7 +48,16 @@ - Do not move + + + + + + + Do not move Locked in place. diff --git a/blockly-workspace-notes/docs/using-notes.md b/blockly-workspace-notes/docs/using-notes.md index 08458ed..c8766cf 100644 --- a/blockly-workspace-notes/docs/using-notes.md +++ b/blockly-workspace-notes/docs/using-notes.md @@ -8,7 +8,7 @@ glance, a colour, and room to write.

Everything a note can do is in the right-click menu. Nothing sits on the paper -except the words on it. +except the words on it, and the pin marker on a note that has been pinned. ## Add one @@ -41,8 +41,12 @@ picking a colour is one click and the menu stays short. ## Pin it -Right-click → **Pin note**. A pinned note cannot be dragged, sits in front of -its neighbours, and draws a heavier edge so you can see why it will not move. +Right-click → **Pin note**. A pinned note cannot be dragged and sits in front +of its neighbours. It says so twice: a pin at the head of the title row, and a +heavier edge around the card. + +The pin is a marker, not a button — there is nothing to click, and it is only +there while the note is pinned. Right-click → **Unpin note** to release it. @@ -77,7 +81,7 @@ resizing, recolouring, renaming, pinning, reordering. ## The states you will see

- A named note, one not named yet, a collapsed note, a pinned note and a selected note + A named note, one not named yet, a collapsed note, a pinned note showing its marker, and a selected note

| State | How you can tell | @@ -85,7 +89,7 @@ resizing, recolouring, renaming, pinning, reordering. | **Named** | The title is bold and black on the paper | | **Not named yet** | A greyed `Title`, and a greyed prompt in the body | | **Collapsed** | Just the title row. The rule disappears with the body | -| **Pinned** | A heavier edge around the card | +| **Pinned** | A pin before the title, and a heavier edge | | **Selected** | Blockly's own gold ring, following the card's corners | ## Keyboard and screen readers diff --git a/blockly-workspace-notes/src/constants/dom.ts b/blockly-workspace-notes/src/constants/dom.ts index 4ca6d47..695a7eb 100644 --- a/blockly-workspace-notes/src/constants/dom.ts +++ b/blockly-workspace-notes/src/constants/dom.ts @@ -15,6 +15,9 @@ export const TITLED_CLASS = 'blocklyNoteTitled'; /** CSS class added to a pinned note. */ export const PINNED_CLASS = 'blocklyNotePinned'; +/** CSS class of the group holding the marker drawn on a pinned note. */ +export const PIN_CLASS = 'blocklyNotePin'; + /** CSS class of the SVG text element that renders a note's title. */ export const TITLE_CLASS = 'blocklyNoteTitle'; diff --git a/blockly-workspace-notes/src/constants/layout.ts b/blockly-workspace-notes/src/constants/layout.ts index 1fbca95..cb4dd57 100644 --- a/blockly-workspace-notes/src/constants/layout.ts +++ b/blockly-workspace-notes/src/constants/layout.ts @@ -36,6 +36,36 @@ export const TITLE_LINE_HEIGHT = 16; */ export const TITLE_FONT_SIZE = TITLE_LINE_HEIGHT; +/** + * The box the pin marker is drawn in. + * + * One line box, so a pinned title row is exactly as tall as an unpinned one and + * the row never needs remeasuring. The glyph itself is authored on a 24-unit + * grid and scaled to fit, which also thins its 2-unit stroke to about 1.3px - + * about right for a mark that should read as punctuation beside the heading + * rather than as a control. + */ +export const PIN_ICON_SIZE = TITLE_LINE_HEIGHT; + +/** The gap between the pin marker and the title it leads. */ +export const PIN_ICON_GAP = MEDIUM_PADDING; + +/** + * The grid the pin glyph is authored on, and where its ink actually sits + * within it. + * + * Tabler draws on 24 units but the pin only occupies x 7-17 and y 4-21, so + * roughly a third of the box is padding. Laying the marker out by that box + * would set it in from the note's margin by the padding and leave a gap to the + * title a third wider than asked for - which is exactly the sort of uneven + * spacing the rest of this layout is built to avoid. So the ink box is what + * gets positioned, and these are its numbers. + */ +export const PIN_GLYPH_GRID = 24; + +/** The pin glyph's ink, in grid units. */ +export const PIN_GLYPH_INK = {x: 7, y: 4, width: 10, height: 17}; + /** * The margin on every side of a note, and the single number the rest of the * layout is built from. diff --git a/blockly-workspace-notes/src/model/note.ts b/blockly-workspace-notes/src/model/note.ts index 6b2c09c..3137566 100644 --- a/blockly-workspace-notes/src/model/note.ts +++ b/blockly-workspace-notes/src/model/note.ts @@ -3,15 +3,17 @@ * state `model/note_mixin.ts` defines. * * Everything here is SVG the note adds to, or takes away from, the comment - * core already built — the card, the title and the rule under it — plus the - * plumbing that keeps that chrome in step with a comment core is resizing, - * collapsing and dragging underneath it. + * core already built — the card, the title, the rule under it, and the marker + * that shows a note is pinned — plus the plumbing that keeps that chrome in + * step with a comment core is resizing, collapsing and dragging underneath + * it. */ import * as Blockly from 'blockly/core'; import { NOTE_CLASS, + PIN_CLASS, PINNED_CLASS, RULE_CLASS, TITLED_CLASS, @@ -22,6 +24,10 @@ import { FRAME_RADIUS, MIN_SIZE, NOTE_MARGIN, + PIN_GLYPH_GRID, + PIN_GLYPH_INK, + PIN_ICON_GAP, + PIN_ICON_SIZE, TITLE_RULE_Y, TOPBAR_HEIGHT, } from '../constants/layout'; @@ -54,6 +60,9 @@ export class Note extends RenderedNoteBase { /** The SVG text element holding the title. */ private titleElement_?: SVGTextElement; + /** The marker shown while the note is pinned. */ + private pin_?: SVGGElement; + /** The text node inside `titleElement_`. */ private titleNode_?: Text; @@ -111,6 +120,42 @@ export class Note extends RenderedNoteBase { */ this.titlePressPoint_ = null; + /** + * The pin marker. Tabler's `pinned` glyph, authored on a 24-unit grid. + * + * It lives in the root group beside the rule, *not* in core's top bar. + * Core mirrors that bar wholesale in RTL and each of its children has to + * undo the mirror for itself; the root group is not mirrored, so the + * marker flips its own coordinates the way `renderRule` does and the two + * stay consistent. + * + * `aria-hidden` because it is decoration: it repeats what `setMovable` + * already tells assistive technology, and a second announcement of the + * same fact is noise. The stylesheet hides it entirely unless the note is + * pinned, and keeps it out of the way of pointer events. + * @private + */ + this.pin_ = Blockly.utils.dom.createSvgElement(Blockly.utils.Svg.G, { + 'class': PIN_CLASS, + 'aria-hidden': 'true', + }); + if (this.rule_) { + root.insertBefore(this.pin_, this.rule_.nextSibling); + } else { + root.appendChild(this.pin_); + } + for (const d of [ + 'M9 4v6l-2 4v2h10v-2l-2 -4v-6', + 'M12 16l0 5', + 'M8 4l8 0', + ]) { + Blockly.utils.dom.createSvgElement( + Blockly.utils.Svg.PATH, + {'d': d}, + this.pin_, + ); + } + /** * The SVG text element showing the title above the writing area. * @private @@ -268,10 +313,12 @@ export class Note extends RenderedNoteBase { } /** - * Draws the title, truncated to the width of the note. + * Draws the title and the pin marker, truncating the title to what is left + * of the width. * * Called on every size change as well as every title change, since the - * truncation depends on both. + * truncation depends on both, and on pinning, which moves the title over to + * make room for the marker. */ renderTitle() { if (!this.titleElement_ || this.isDeadOrDying()) return; @@ -289,14 +336,26 @@ export class Note extends RenderedNoteBase { if (!this.titleNode_ || !this.titleElement_) return; this.titleNode_.textContent = title; - this.titleElement_.setAttribute( - 'x', - `${this.workspace.RTL ? -NOTE_MARGIN : NOTE_MARGIN}`, - ); + // The marker leads the title, so it is what the title is indented past. + // Nothing in core makes room for it: `calcMinSize` sums its own two bar + // buttons by name, so this offset is the only thing keeping the two from + // overlapping. + const indent = this.isPinned() + ? (PIN_GLYPH_INK.width * PIN_ICON_SIZE) / PIN_GLYPH_GRID + PIN_ICON_GAP + : 0; + // Inside core's top bar group, which it mirrors in RTL - so x counts + // inwards from the note's edge either way, and the sign follows. + const dir = this.workspace.RTL ? -1 : 1; + this.positionPin_(dir); + + this.titleElement_.setAttribute('x', `${dir * (NOTE_MARGIN + indent)}`); this.titleElement_.setAttribute('y', `${TOPBAR_HEIGHT / 2}`); // Trim a character at a time; titles are short, so this settles fast. - const maxWidth = Math.max(0, this.view.getSize().width - NOTE_MARGIN * 2); + const maxWidth = Math.max( + 0, + this.view.getSize().width - NOTE_MARGIN * 2 - indent, + ); let text = title; while ( text.length > 1 && @@ -307,6 +366,30 @@ export class Note extends RenderedNoteBase { } } + /** + * Places the pin marker at the start of the title row. + * + * The glyph is authored on a 24-unit grid, so it is scaled down to one line + * box. In RTL the whole top bar is mirrored by core, and the negative scale + * undoes that for the glyph itself - the same correction the title gets from + * the stylesheet. + * + * @param dir 1 in a left-to-right workspace, -1 in a right-to-left one. + */ + private positionPin_(dir: number) { + if (!this.pin_) return; + const scale = PIN_ICON_SIZE / PIN_GLYPH_GRID; + // Offset by the glyph's own padding so it is the ink that lands on the + // margin, level with the body text below, rather than the box around it. + const x = dir * (NOTE_MARGIN - PIN_GLYPH_INK.x * scale); + const y = + TOPBAR_HEIGHT / 2 - (PIN_GLYPH_INK.y + PIN_GLYPH_INK.height / 2) * scale; + this.pin_.setAttribute( + 'transform', + `translate(${x}, ${y}) scale(${dir * scale}, ${scale})`, + ); + } + /** Locks or unlocks the note, and marks it visually. */ applyPinned() { super.applyPinned(); @@ -315,6 +398,9 @@ export class Note extends RenderedNoteBase { this.getSvgRoot(), PINNED_CLASS, ); + // The marker takes room from the title, so the row has to be laid out + // again. Nothing else would do it until the next resize. + this.renderTitle(); if (pinned) this.applyZIndex(); } diff --git a/blockly-workspace-notes/src/ui/context_menu.ts b/blockly-workspace-notes/src/ui/context_menu.ts index 571c575..f8e41ed 100644 --- a/blockly-workspace-notes/src/ui/context_menu.ts +++ b/blockly-workspace-notes/src/ui/context_menu.ts @@ -24,6 +24,20 @@ import {createSwatchRow} from './colour_swatches'; const ScopeType = Blockly.ContextMenuRegistry.ScopeType; +/** + * How many plugin instances are holding these items. + * + * The context menu registry is a global singleton while the plugin is + * per-workspace, so registration is reference-counted the same way the + * serializer, paster and XML wrappers are. Without it a second workspace - or + * a host that re-injects one - throws on the first duplicate item id, and the + * first one out would take the menu away from the ones still running. + * + * The palette belongs to whichever instance registers first, matching what + * `registerNoteSerializers` does with its own options. + */ +let registrationCount = 0; + /** IDs of the items this module registers, in the order they are added. */ const NOTE_ITEM_IDS = [ 'noteColour', @@ -49,6 +63,7 @@ function noteFromScope(scope: Blockly.ContextMenuRegistry.Scope): Note | null { * @param options.palette The colour swatches to offer. */ export function registerNoteContextMenu({palette = DEFAULT_PALETTE} = {}) { + if (registrationCount++) return; const registry = Blockly.ContextMenuRegistry.registry; // Core does not register these by default; without them there is no @@ -214,6 +229,8 @@ export function registerNoteContextMenu({palette = DEFAULT_PALETTE} = {}) { * Removes the note items and restores core's `commentCreate`. */ export function unregisterNoteContextMenu(): void { + if (--registrationCount > 0) return; + registrationCount = 0; const registry = Blockly.ContextMenuRegistry.registry; for (const id of NOTE_ITEM_IDS) { diff --git a/blockly-workspace-notes/src/ui/css.ts b/blockly-workspace-notes/src/ui/css.ts index 0d93c48..4d15f74 100644 --- a/blockly-workspace-notes/src/ui/css.ts +++ b/blockly-workspace-notes/src/ui/css.ts @@ -24,6 +24,7 @@ import * as Blockly from 'blockly/core'; import { NOTE_CLASS, + PIN_CLASS, PINNED_CLASS, RULE_CLASS, TITLED_CLASS, @@ -49,13 +50,43 @@ Blockly.Css.register(` } /* - * A pinned note is locked in place, and says so with a heavier edge - the - * quietest mark available now that the bar carries no icons. + * A pinned note is locked in place, and says so twice: a marker at the head of + * the title row, and a heavier edge. + * + * Two quiet signals rather than one loud one. The edge is what carries at a + * glance across a busy workspace, and it is still legible when a long title has + * pushed the marker to the very corner of the note. */ .${NOTE_CLASS}.${PINNED_CLASS} .blocklyCommentHighlight { stroke-width: 2px; } +/* + * The marker itself: Tabler's pin, stroked rather than filled so it sits at the + * weight of the heading it leads rather than as a solid blot on the paper. + * + * display:none rather than visibility, matching how core hides its own bar + * buttons - CommentBarButton.canBeFocused() defers to checkVisibility(), so a + * display:none element is skipped by keyboard navigation rather than trapped + * on. It is decoration either way, and aria-hidden in the markup. + * + * pointer-events:none because the title row is the note's drag handle. A note + * has to stay draggable by the part of the row the marker occupies. + */ +.${NOTE_CLASS} .${PIN_CLASS} { + display: none; + fill: none; + stroke: #000; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; + pointer-events: none; +} + +.${NOTE_CLASS}.${PINNED_CLASS} .${PIN_CLASS} { + display: block; +} + /* * No header strip: the title sits on the paper. The rect stays, because core * measures its rendered height and derives the writing area's offset from it. @@ -66,10 +97,12 @@ Blockly.Css.register(` } /* - * Every action is in the context menu, so the bar carries no buttons. CSS is - * how core hides one itself - it ships .blocklyDeleteIcon as display:none - - * and CommentBarButton.canBeFocused() defers to checkVisibility(), so - * keyboard navigation skips a hidden button rather than trapping on it. + * Every action is in the context menu, so the bar carries no buttons - the pin + * marker above is a state marker, not a control, and nothing on the row can be + * clicked. CSS is how core hides one itself - it ships .blocklyDeleteIcon as + * display:none - and CommentBarButton.canBeFocused() defers to + * checkVisibility(), so keyboard navigation skips a hidden button rather than + * trapping on it. */ .${NOTE_CLASS} .blocklyFoldoutIcon, .${NOTE_CLASS} .blocklyDeleteIcon { From 85cfd03b379f11db5ed7e583f21ba4006d4d5064 Mon Sep 17 00:00:00 2001 From: Preet Vadaliya Date: Wed, 9 Sep 2026 02:14:18 +0200 Subject: [PATCH 10/20] feat: rebuild the note to Blockly's comment pattern --- blockly-workspace-notes/README.md | 2 +- blockly-workspace-notes/docs/design.md | 55 ++++--- .../docs/images/note-anatomy.svg | 84 +++++----- .../docs/images/note-states.svg | 124 +++++++++------ blockly-workspace-notes/docs/using-notes.md | 34 ++-- .../src/constants/colours.ts | 30 +++- blockly-workspace-notes/src/constants/dom.ts | 3 - .../src/constants/layout.ts | 55 +++++-- blockly-workspace-notes/src/model/note.ts | 145 ++++++++---------- blockly-workspace-notes/src/ui/css.ts | 86 +++++++---- blockly-workspace-notes/src/ui/icons.ts | 65 ++++++++ .../src/ui/title_editor.ts | 12 +- blockly-workspace-notes/src/utils/colour.ts | 23 +++ blockly-workspace-notes/test/colour.mocha.js | 79 +++++++++- 14 files changed, 537 insertions(+), 260 deletions(-) create mode 100644 blockly-workspace-notes/src/ui/icons.ts diff --git a/blockly-workspace-notes/README.md b/blockly-workspace-notes/README.md index d9e4301..e3b9502 100644 --- a/blockly-workspace-notes/README.md +++ b/blockly-workspace-notes/README.md @@ -6,7 +6,7 @@ Sticky notes for a Blockly workspace: draggable, resizable, colour-coded paper with a title, an author and a stacking order.

- The parts of a note: title, rule, card, body and resize handle + The parts of a note: title bar, collapse and delete buttons, title, body and resize handle

Notes extend Blockly's own workspace comments, so dragging, resizing, diff --git a/blockly-workspace-notes/docs/design.md b/blockly-workspace-notes/docs/design.md index eeaa27c..53f3010 100644 --- a/blockly-workspace-notes/docs/design.md +++ b/blockly-workspace-notes/docs/design.md @@ -35,21 +35,27 @@ fixed. The cost is that the design is tied to how Blockly's comments behave. If Blockly changes them, this has to follow. -### Why a note has no buttons +### Why the note looks like a Blockly comment -Every early round put icons in the title row — collapse, delete, pin — and -every round the note read as a _block_, because a strip of icons across the top -of a rectangle is exactly what a block looks like. Moving all of it into the -right-click menu leaves nothing on the paper but the words on it. +A note is a Blockly workspace comment, so it looks like one: square, thin +bordered, with a title bar in its own colour and its controls in that bar. -The cost is that collapsing is one click further away and slightly less -discoverable. +An earlier design went the other way — a rounded card with no bar and no +buttons, everything in the right-click menu — on the theory that a rectangle +with a strip of icons across the top is what a block looks like. It read well, +but it made a note something you had to learn. Following the pattern people +already know is worth more than avoiding a family resemblance, and the colour +does the work of telling a note from a block. -The one mark that did earn a place is the pin, and the distinction is that it -is not a control. It does nothing when clicked, it appears only while a note is -pinned, and it answers a question the note could not otherwise answer: why will -this one not move? A strip of buttons that is always there reads as a block; a -single mark that is usually absent reads as punctuation. +Collapse and delete are Blockly's own buttons, not copies. They are hidden by +default and this simply shows them, which is why they arrive with keyboard +navigation, focus handling, ARIA labels and the collapse button's automatic +relabelling between "Collapse Comment" and "Expand Comment" already working. All +that is replaced is the artwork, redrawn in the note's own ink. + +The pin beside them is a marker rather than a control: it does nothing when +clicked, and it is there only while a note is pinned, answering the one question +the note could not otherwise answer — why will this one not move? ### Why the title is edited in place @@ -74,16 +80,25 @@ heads-up display. Locked won: a note that floats over your blocks wherever you pan is more annoying than helpful, and the screen-fixed version fights the way a workspace scrolls and zooms. -### Why colours are pale +### Why every colour comes from one + +A note stores a single colour. Everything else is derived from it: the title bar +and border a step darker, and the ink — the title, the body text and all three +glyphs — the same hue taken dark and saturated. + +Nothing is hardcoded black, and that is the point. Black furniture on a coloured +card looks like a coloured card with black furniture on it; a green note whose +text and icons are deep green reads as one object. It also means a host can +supply any palette it likes and the whole note follows. -Notes sit among coloured blocks. A saturated note would compete with them and -make the workspace harder to read. +The one thing derivation cannot guarantee is readability, so that is tested +rather than trusted: across the palette the worst contrast is 5.79:1 for ink on +the bar and 7.67:1 on the body, both clear of the 4.5:1 that body text needs, +and `test/colour.mocha.js` asserts it. -Colour alone was not enough, though. Several rounds of tuning hue, icon colour -and text colour all failed, because what reads as a block is the _shape_. Once -the shape was fixed — rounded card, no header strip, no boxed text — the -palette could be pulled paler still. The two work together: a note is S 0.25 at -V 0.98, against a block's S 0.45 at V 0.65. +The fills themselves stay pale because notes sit among coloured blocks, and a +saturated note would compete with them: S 0.25 at V 0.98, against a block's +S 0.45 at V 0.65. ### Why notes get their own place in the save file diff --git a/blockly-workspace-notes/docs/images/note-anatomy.svg b/blockly-workspace-notes/docs/images/note-anatomy.svg index 2248422..da7de41 100644 --- a/blockly-workspace-notes/docs/images/note-anatomy.svg +++ b/blockly-workspace-notes/docs/images/note-anatomy.svg @@ -1,65 +1,75 @@ - + The parts of a note - A note at its default size of 260 by 180, with its title, hairline rule, card, body and resize handle labelled. + A note at its default size of 260 by 180, with its title bar, collapse and delete buttons, title, body and resize handle labelled. - + - - - + + + + - - Shopping list + + + + + + + + + + + + + - - + Shopping list - - - milk - bread - coffee + + milk + bread + coffee - - + - - - - + + + - + - Title - click it and type + Collapse + folds the note to its bar - The card - pale fill, hairline edge + The card + square, thin border - Rule - equal air above and below + Delete + one undo brings it back The body - written onto the paper, - with no box around it + written in the note's own ink - Resize handle - inside the sheet, not on its corner + Resize handle + inside the sheet, not on its corner - + - - - + + + - a margin of 16, the same on every side + the title bar, one step darker than the body diff --git a/blockly-workspace-notes/docs/images/note-states.svg b/blockly-workspace-notes/docs/images/note-states.svg index 61a0b0d..0cf798d 100644 --- a/blockly-workspace-notes/docs/images/note-states.svg +++ b/blockly-workspace-notes/docs/images/note-states.svg @@ -1,6 +1,6 @@ The states a note can be in - Five notes at their default size: a named note, one not named yet, a collapsed one, a pinned one showing its pin marker, and a selected one. + Five notes at their default size: a named note, one not named yet, a collapsed one, a pinned one showing its marker, and a selected one. @@ -13,61 +13,99 @@ the title is what you scan for - greyed placeholders, both rows - the title row is the whole note - a pin before the title, and a heavier edge + faded placeholders, both rows + the bar is the whole note + a pin in the bar, and a heavier edge the ring follows the card - + - - Shopping list - - - milk - bread - coffee + + + + + + + + + + Shopping list + milk - - + - - Title - - Say something... + + + + + + + + + + + Title + Say something... - - + - - Release checklist + + + + + + + + + + + Release checklist - - + - - - - - - + + + + + + + + + + + + - Do not move - - Locked in place. + Do not move + Locked in place. - - + - - - Ideas - - Try a smaller step. + + + + + + + + + + + + Ideas + Try a smaller step. diff --git a/blockly-workspace-notes/docs/using-notes.md b/blockly-workspace-notes/docs/using-notes.md index c8766cf..d54a405 100644 --- a/blockly-workspace-notes/docs/using-notes.md +++ b/blockly-workspace-notes/docs/using-notes.md @@ -4,11 +4,12 @@ A note is a sheet of paper on the workspace. It has a title you can read at a glance, a colour, and room to write.

- The parts of a note: title, rule, card, body and resize handle + The parts of a note: title bar, collapse and delete buttons, title, body and resize handle

-Everything a note can do is in the right-click menu. Nothing sits on the paper -except the words on it, and the pin marker on a note that has been pinned. +The title bar carries two buttons — collapse on the left, delete on the right — +and a pin marker appears between them when a note is pinned. Everything else a +note can do is in the right-click menu. ## Add one @@ -42,19 +43,19 @@ picking a colour is one click and the menu stays short. ## Pin it Right-click → **Pin note**. A pinned note cannot be dragged and sits in front -of its neighbours. It says so twice: a pin at the head of the title row, and a -heavier edge around the card. +of its neighbours. It says so twice: a pin in the title bar, and a heavier edge +around the card. The pin is a marker, not a button — there is nothing to click, and it is only -there while the note is pinned. - -Right-click → **Unpin note** to release it. +there while the note is pinned. Right-click → **Unpin note** to release it. ## Collapse it -Right-click → **Collapse note** to fold it down to its title row. The title -stays readable. This is how you keep a long note around without it covering -your blocks. +Press the chevron at the left of the title bar to fold a note down to that bar, +or right-click → **Collapse note**. The title stays readable, and the chevron +turns to point right. Press it again to open the note back up. + +This is how you keep a long note around without it covering your blocks. ## Order them @@ -72,7 +73,8 @@ on the workspace. grabbed. - **Duplicate note**, or copy and paste. The copy keeps the title, colour and pinned state, and lands slightly offset. -- **Delete note**, or press Delete while it is selected. +- **Delete note** from the menu, the bin at the right of the title bar, or + Delete while the note is selected. One press of undo brings a deleted note back complete — same text, same title, same colour, same pinned state. Every other change is undoable too: typing, @@ -86,10 +88,10 @@ resizing, recolouring, renaming, pinning, reordering. | State | How you can tell | | ----------------- | ----------------------------------------------------- | -| **Named** | The title is bold and black on the paper | -| **Not named yet** | A greyed `Title`, and a greyed prompt in the body | -| **Collapsed** | Just the title row. The rule disappears with the body | -| **Pinned** | A pin before the title, and a heavier edge | +| **Named** | The title is bold, in the note's own ink | +| **Not named yet** | A faded `Title`, and a faded prompt in the body | +| **Collapsed** | Just the title bar, and the chevron points right | +| **Pinned** | A pin in the title bar, and a heavier edge | | **Selected** | Blockly's own gold ring, following the card's corners | ## Keyboard and screen readers diff --git a/blockly-workspace-notes/src/constants/colours.ts b/blockly-workspace-notes/src/constants/colours.ts index 0380e8f..68efed1 100644 --- a/blockly-workspace-notes/src/constants/colours.ts +++ b/blockly-workspace-notes/src/constants/colours.ts @@ -49,6 +49,34 @@ export const DEFAULT_PALETTE: PaletteEntry[] = [ /** * How far a note's edge sits below its own colour: the same hue, a step down - * in value. Used for the card's hairline and the writing area's border. + * in value. Used for the note's border and for the title bar, which Blockly + * paints from the same `--commentBorderColour` the border reads. */ export const EDGE_VALUE_SCALE = 0.88; + +/** + * How the ink is derived from a note's colour: the same hue, taken far darker + * and a good deal more saturated. + * + * Everything written on a note is drawn in it — the title, the body text, the + * three glyphs in the title bar — so it is the one colour that has to stay + * readable on both surfaces a note has, the pale body and the slightly deeper + * bar. Deriving it from the note's own hue rather than reaching for black is + * what makes a green note read as a green note all the way through. + * + * The saturation is scaled up rather than fixed, so a near-grey note keeps + * near-grey text instead of acquiring a colour cast, and capped, so a vivid one + * does not go lurid at this value. + * + * Checked across the palette: the worst contrast these produce is 5.79:1 on the + * bar and 7.67:1 on the body, both clear of the 4.5:1 needed for body text. + * `test/colour.mocha.js` asserts that, so a change here cannot quietly make a + * note unreadable. + */ +export const INK_SATURATION_SCALE = 2.8; + +/** The ceiling on the scaled-up ink saturation. */ +export const INK_SATURATION_MAX = 0.72; + +/** How dark the ink sits, on the 0-1 value scale. */ +export const INK_VALUE = 0.3; diff --git a/blockly-workspace-notes/src/constants/dom.ts b/blockly-workspace-notes/src/constants/dom.ts index 695a7eb..cf9cbc6 100644 --- a/blockly-workspace-notes/src/constants/dom.ts +++ b/blockly-workspace-notes/src/constants/dom.ts @@ -21,9 +21,6 @@ export const PIN_CLASS = 'blocklyNotePin'; /** CSS class of the SVG text element that renders a note's title. */ export const TITLE_CLASS = 'blocklyNoteTitle'; -/** CSS class of the hairline drawn under a note's title. */ -export const RULE_CLASS = 'blocklyNoteRule'; - /** * Shown in the title row of a note that has not been named yet. * diff --git a/blockly-workspace-notes/src/constants/layout.ts b/blockly-workspace-notes/src/constants/layout.ts index cb4dd57..9e10ad0 100644 --- a/blockly-workspace-notes/src/constants/layout.ts +++ b/blockly-workspace-notes/src/constants/layout.ts @@ -87,6 +87,44 @@ export const NOTE_MARGIN = TITLE_LINE_HEIGHT; */ export const TOPBAR_HEIGHT = TITLE_LINE_HEIGHT + NOTE_MARGIN * 2; +/** + * The box each of core's bar buttons is drawn in. Core's own default, kept. + */ +export const BAR_ICON_SIZE = 20; + +/** The gap between a bar button and whatever it sits next to. */ +export const BAR_ICON_GAP = MEDIUM_PADDING; + +/** + * The inset core leaves around a bar button. + * + * Not ours to choose: `CommentBarButton.getMargin()` computes exactly this, + * half the difference between the bar's height and the icon's, and both + * buttons are positioned with it. Repeating the arithmetic here is what lets + * the title know where they are without measuring them every time it redraws. + */ +export const BAR_ICON_MARGIN = (TOPBAR_HEIGHT - BAR_ICON_SIZE) / 2; + +/** + * Where the title row's contents begin, past the collapse button. + * + * Core puts that button at `BAR_ICON_MARGIN` from the leading edge, so this is + * its far side plus a gap. The pin marker starts here when there is one, and + * the title follows it. + */ +export const BAR_LEADING_INSET = BAR_ICON_MARGIN + BAR_ICON_SIZE + BAR_ICON_GAP; + +/** + * How much room the delete button needs at the trailing edge. + * + * Core sets its x to the bar's width minus the button's size *including both + * margins*, so the icon's near edge is one full margin further in than the + * collapse button's - hence the doubled margin here rather than a single one. + * This is what the title's truncation has to stop short of. + */ +export const BAR_TRAILING_INSET = + BAR_ICON_MARGIN * 2 + BAR_ICON_SIZE + BAR_ICON_GAP; + /** Corner radius of a note's card; Blockly's own CORNER_RADIUS. */ export const FRAME_RADIUS = 8; @@ -100,23 +138,6 @@ export const SCROLLBAR_WIDTH = 8; /** Space between a note's edge and its writing area. */ export const BODY_INSET = NOTE_MARGIN; -/** The bottom of the title's line box, measured from the note's top. */ -const TITLE_BOTTOM = (TOPBAR_HEIGHT + TITLE_LINE_HEIGHT) / 2; - -/** - * Where the hairline under the title sits, measured from the note's top. - * - * Exactly midway between the bottom of the title's line box and the top of - * the body, so it has equal air above and below and does not read as - * belonging to either one. - * - * It separates the heading from the body the way the rule on an index card - * does. A box around the body would not: a lighter bordered panel inset in a - * coloured body is exactly how Blockly draws a field on a block, so anything - * built that way reads as a block however the note itself is shaped. - */ -export const TITLE_RULE_Y = (TITLE_BOTTOM + TOPBAR_HEIGHT) / 2; - /** * The size a note is created at when the host sets no `defaultSize`. * diff --git a/blockly-workspace-notes/src/model/note.ts b/blockly-workspace-notes/src/model/note.ts index 3137566..0262113 100644 --- a/blockly-workspace-notes/src/model/note.ts +++ b/blockly-workspace-notes/src/model/note.ts @@ -3,10 +3,9 @@ * state `model/note_mixin.ts` defines. * * Everything here is SVG the note adds to, or takes away from, the comment - * core already built — the card, the title, the rule under it, and the marker - * that shows a note is pinned — plus the plumbing that keeps that chrome in - * step with a comment core is resizing, collapsing and dragging underneath - * it. + * core already built — the card, the title, and the marker that shows a note + * is pinned — plus the plumbing that keeps that chrome in step with a comment + * core is resizing, collapsing and dragging underneath it. */ import * as Blockly from 'blockly/core'; @@ -15,24 +14,28 @@ import { NOTE_CLASS, PIN_CLASS, PINNED_CLASS, - RULE_CLASS, TITLED_CLASS, TITLE_CLASS, UNTITLED_TITLE_TEXT, } from '../constants/dom'; import { - FRAME_RADIUS, + BAR_LEADING_INSET, + BAR_TRAILING_INSET, MIN_SIZE, - NOTE_MARGIN, PIN_GLYPH_GRID, PIN_GLYPH_INK, PIN_ICON_GAP, PIN_ICON_SIZE, - TITLE_RULE_Y, TOPBAR_HEIGHT, } from '../constants/layout'; import type {NoteCopyData} from '../types/clipboard'; -import {edgeFor} from '../utils/colour'; +import {edgeFor, inkFor} from '../utils/colour'; +import { + CHEVRON_GLYPH, + PIN_GLYPH, + TRASH_GLYPH, + glyphToDataUri, +} from '../ui/icons'; import {editTitle} from '../ui/title_editor'; import {RenderedNoteBase} from './note_mixin'; import {restackNotes} from './stacking'; @@ -51,9 +54,6 @@ export class Note extends RenderedNoteBase { */ private card_?: SVGRectElement | null; - /** The rule under the title. */ - private rule_?: SVGLineElement; - /** Where a press on the title started, while one is in progress. */ private titlePressPoint_?: {x: number; y: number} | null; @@ -63,6 +63,18 @@ export class Note extends RenderedNoteBase { /** The marker shown while the note is pinned. */ private pin_?: SVGGElement; + /** + * Core's two bar buttons. + * + * Kept only so their artwork can be retinted when the note is recoloured. + * Everything else about them - where they sit, what they do, their focus and + * ARIA - stays core's business. + */ + private foldoutIcon_?: SVGImageElement | null; + + /** See `foldoutIcon_`. */ + private deleteIcon_?: SVGImageElement | null; + /** The text node inside `titleElement_`. */ private titleNode_?: Text; @@ -79,6 +91,8 @@ export class Note extends RenderedNoteBase { const root = this.getSvgRoot(); Blockly.utils.dom.addClass(root, NOTE_CLASS); const topBar = root.querySelector('.blocklyCommentTopbar'); + this.foldoutIcon_ = root.querySelector('.blocklyFoldoutIcon'); + this.deleteIcon_ = root.querySelector('.blocklyDeleteIcon'); /** * The card itself: core's own highlight rect, which a note fills rather @@ -86,48 +100,19 @@ export class Note extends RenderedNoteBase { * a drag and strokes it when the note is selected, so borrowing it keeps * both for free. * - * The corners are set once. Core only ever writes height, width and x to - * this rect, so the radii survive every resize. + * Square, like Blockly's own comment: core leaves this rect unrounded and + * a note no longer overrides that. * @private */ this.card_ = root.querySelector('.blocklyCommentHighlight'); - this.card_?.setAttribute('rx', `${FRAME_RADIUS}`); - this.card_?.setAttribute('ry', `${FRAME_RADIUS}`); - - /** - * The hairline under the title. - * - * The heading needs separating from the body, and a box around the body - * is the one thing that cannot do it: a lighter bordered panel inset in a - * coloured body is exactly how Blockly draws a field on a block, so a - * note built that way reads as a block however it is shaped. A rule reads - * as an index card instead. - * @private - */ - this.rule_ = Blockly.utils.dom.createSvgElement(Blockly.utils.Svg.LINE, { - 'class': RULE_CLASS, - }); - if (this.card_) { - root.insertBefore(this.rule_, this.card_.nextSibling); - } else { - root.appendChild(this.rule_); - } - - /** - * Where the pointer went down on the title, so a press that turns into a - * drag can be told apart from a click. - * @private - */ - this.titlePressPoint_ = null; /** * The pin marker. Tabler's `pinned` glyph, authored on a 24-unit grid. * - * It lives in the root group beside the rule, *not* in core's top bar. - * Core mirrors that bar wholesale in RTL and each of its children has to - * undo the mirror for itself; the root group is not mirrored, so the - * marker flips its own coordinates the way `renderRule` does and the two - * stay consistent. + * It lives in the root group, *not* in core's top bar. Core mirrors that + * bar wholesale in RTL and each of its children has to undo the mirror for + * itself; the root group is not mirrored, so the marker flips its own + * coordinates instead. * * `aria-hidden` because it is decoration: it repeats what `setMovable` * already tells assistive technology, and a second announcement of the @@ -139,16 +124,14 @@ export class Note extends RenderedNoteBase { 'class': PIN_CLASS, 'aria-hidden': 'true', }); - if (this.rule_) { - root.insertBefore(this.pin_, this.rule_.nextSibling); + // After the top bar in document order, so it paints on top of it. The bar + // is opaque now, and anything inserted before it is simply covered. + if (topBar) { + root.insertBefore(this.pin_, topBar.nextSibling); } else { root.appendChild(this.pin_); } - for (const d of [ - 'M9 4v6l-2 4v2h10v-2l-2 -4v-6', - 'M12 16l0 5', - 'M8 4l8 0', - ]) { + for (const d of PIN_GLYPH) { Blockly.utils.dom.createSvgElement( Blockly.utils.Svg.PATH, {'d': d}, @@ -260,7 +243,6 @@ export class Note extends RenderedNoteBase { renderChrome() { if (this.isDeadOrDying()) return; this.renderCard(); - this.renderRule(); this.renderTitle(); } @@ -279,24 +261,6 @@ export class Note extends RenderedNoteBase { this.card_.setAttribute('height', `${this.view.getSize().height}`); } - /** - * Stretches the hairline to the width of the note. - * - * It is inset to the title's own gutter rather than running edge to edge, - * so it starts where the heading starts. The stylesheet hides it on a - * collapsed note, where there is no body left to divide it from. - */ - renderRule() { - if (!this.rule_) return; - const {width} = this.view.getSize(); - const dir = this.workspace.RTL ? -1 : 1; - const inset = Math.min(NOTE_MARGIN, width / 2); - this.rule_.setAttribute('x1', `${dir * inset}`); - this.rule_.setAttribute('x2', `${dir * Math.max(inset, width - inset)}`); - this.rule_.setAttribute('y1', `${TITLE_RULE_Y}`); - this.rule_.setAttribute('y2', `${TITLE_RULE_Y}`); - } - /** * Paints the note by overriding the CSS custom properties core's comment * stylesheet already reads, which avoids restyling its elements directly. @@ -307,9 +271,19 @@ export class Note extends RenderedNoteBase { */ renderColour() { const colour = this.getColour(); + const ink = inkFor(colour); const style = this.getSvgRoot().style; style.setProperty('--commentFillColour', colour); style.setProperty('--commentBorderColour', edgeFor(colour)); + style.setProperty('--noteInkColour', ink); + + // Core's bar buttons are elements, which no amount of CSS can + // tint, so the artwork is swapped for one already drawn in this note's + // ink. Core reads nothing back off them but their bounding box, id and + // visibility - none of which the picture affects - so the buttons stay + // entirely core's, keyboard handling and all. + this.foldoutIcon_?.setAttribute('href', glyphToDataUri(CHEVRON_GLYPH, ink)); + this.deleteIcon_?.setAttribute('href', glyphToDataUri(TRASH_GLYPH, ink)); } /** @@ -336,25 +310,28 @@ export class Note extends RenderedNoteBase { if (!this.titleNode_ || !this.titleElement_) return; this.titleNode_.textContent = title; - // The marker leads the title, so it is what the title is indented past. - // Nothing in core makes room for it: `calcMinSize` sums its own two bar - // buttons by name, so this offset is the only thing keeping the two from - // overlapping. - const indent = this.isPinned() + // The title is boxed in on both sides: the collapse button before it, the + // delete button after it, and the pin marker in between when there is one. + // Core makes room for none of that - `calcMinSize` sums only its own two + // buttons, and nothing at all knows about the marker - so these insets are + // the only thing keeping the four from overlapping. + const pinAdvance = this.isPinned() ? (PIN_GLYPH_INK.width * PIN_ICON_SIZE) / PIN_GLYPH_GRID + PIN_ICON_GAP : 0; + const leading = BAR_LEADING_INSET + pinAdvance; + // Inside core's top bar group, which it mirrors in RTL - so x counts // inwards from the note's edge either way, and the sign follows. const dir = this.workspace.RTL ? -1 : 1; this.positionPin_(dir); - this.titleElement_.setAttribute('x', `${dir * (NOTE_MARGIN + indent)}`); + this.titleElement_.setAttribute('x', `${dir * leading}`); this.titleElement_.setAttribute('y', `${TOPBAR_HEIGHT / 2}`); // Trim a character at a time; titles are short, so this settles fast. const maxWidth = Math.max( 0, - this.view.getSize().width - NOTE_MARGIN * 2 - indent, + this.view.getSize().width - leading - BAR_TRAILING_INSET, ); let text = title; while ( @@ -367,7 +344,7 @@ export class Note extends RenderedNoteBase { } /** - * Places the pin marker at the start of the title row. + * Places the pin marker between the collapse button and the title. * * The glyph is authored on a 24-unit grid, so it is scaled down to one line * box. In RTL the whole top bar is mirrored by core, and the negative scale @@ -379,9 +356,9 @@ export class Note extends RenderedNoteBase { private positionPin_(dir: number) { if (!this.pin_) return; const scale = PIN_ICON_SIZE / PIN_GLYPH_GRID; - // Offset by the glyph's own padding so it is the ink that lands on the - // margin, level with the body text below, rather than the box around it. - const x = dir * (NOTE_MARGIN - PIN_GLYPH_INK.x * scale); + // Offset by the glyph's own padding so it is the ink that starts where the + // title row's contents do, rather than the empty box around it. + const x = dir * (BAR_LEADING_INSET - PIN_GLYPH_INK.x * scale); const y = TOPBAR_HEIGHT / 2 - (PIN_GLYPH_INK.y + PIN_GLYPH_INK.height / 2) * scale; this.pin_.setAttribute( diff --git a/blockly-workspace-notes/src/ui/css.ts b/blockly-workspace-notes/src/ui/css.ts index 4d15f74..9753cd6 100644 --- a/blockly-workspace-notes/src/ui/css.ts +++ b/blockly-workspace-notes/src/ui/css.ts @@ -26,11 +26,12 @@ import { NOTE_CLASS, PIN_CLASS, PINNED_CLASS, - RULE_CLASS, TITLED_CLASS, TITLE_CLASS, } from '../constants/dom'; import { + BAR_ICON_MARGIN, + BAR_ICON_SIZE, BODY_INSET, SCROLLBAR_WIDTH, TITLE_FONT_SIZE, @@ -76,7 +77,7 @@ Blockly.Css.register(` .${NOTE_CLASS} .${PIN_CLASS} { display: none; fill: none; - stroke: #000; + stroke: var(--noteInkColour); stroke-width: 2px; stroke-linecap: round; stroke-linejoin: round; @@ -88,25 +89,57 @@ Blockly.Css.register(` } /* - * No header strip: the title sits on the paper. The rect stays, because core - * measures its rendered height and derives the writing area's offset from it. + * The title bar, in the same shade as the border. + * + * Core already paints this rect from --commentBorderColour and a note simply + * lets it, so the two-tone note is Blockly's own model rather than anything + * built on top of it. Only the height is ours: core's 24px bar is sized for a + * strip of icons, and a note needs a line of title between two margins. Core + * measures this rect's rendered height and derives the writing area's offset + * from it, so the number has to be set here rather than drawn around. */ .${NOTE_CLASS} .blocklyCommentTopbarBackground { - fill: none; height: ${TOPBAR_HEIGHT}px; } /* - * Every action is in the context menu, so the bar carries no buttons - the pin - * marker above is a state marker, not a control, and nothing on the row can be - * clicked. CSS is how core hides one itself - it ships .blocklyDeleteIcon as - * display:none - and CommentBarButton.canBeFocused() defers to - * checkVisibility(), so keyboard navigation skips a hidden button rather than - * trapping on it. + * Core's two bar buttons, collapse and delete. + * + * Core ships the delete button display:none, so showing it is the whole of + * that. Both keep everything core gives them - position, focus, ARIA, the + * collapse button's relabelling between "Collapse Comment" and "Expand + * Comment" - because they are still core's buttons; only their artwork is + * swapped, in renderColour, for a copy drawn in this note's ink. + * + * The size is core's own 20px. Its transform-origin is not: core hardcodes + * 12px 12px so the collapsed rule can rotate the chevron about its middle. + * That number is in user space, not relative to the icon's own box - it works + * for core because core's 24px bar leaves a margin of 2, putting a 20px icon's + * centre at 12. A 48px bar leaves a margin of 14, so the centre is 24, and + * using core's number would swing the chevron off the note entirely. */ .${NOTE_CLASS} .blocklyFoldoutIcon, .${NOTE_CLASS} .blocklyDeleteIcon { - display: none; + display: block; + width: ${BAR_ICON_SIZE}px; + height: ${BAR_ICON_SIZE}px; +} + +.${NOTE_CLASS} .blocklyFoldoutIcon { + transform-origin: ${BAR_ICON_MARGIN + BAR_ICON_SIZE / 2}px + ${BAR_ICON_MARGIN + BAR_ICON_SIZE / 2}px; +} + +/* + * Core styles no focus ring for these, so a keyboard user would otherwise get + * the browser's default outline on a bare . The note's own edge colour + * keeps it in the same family as everything else on the card. + */ +.${NOTE_CLASS} .blocklyFoldoutIcon:focus-visible, +.${NOTE_CLASS} .blocklyDeleteIcon:focus-visible { + outline: 2px solid var(--noteInkColour); + outline-offset: 1px; + border-radius: 2px; } /* @@ -143,6 +176,7 @@ Blockly.Css.register(` */ .${NOTE_CLASS} .blocklyTextarea { background-color: transparent; + color: var(--noteInkColour); border: none; padding: 0; scrollbar-width: thin; @@ -194,19 +228,6 @@ Blockly.Css.register(` } } -/* - * The hairline under the title. Hidden on a collapsed note, where the title - * row is the whole note and there is no body to divide it from. - */ -.${NOTE_CLASS} .${RULE_CLASS} { - stroke: var(--commentBorderColour); - stroke-width: 1px; -} - -.${NOTE_CLASS}.blocklyCollapsed .${RULE_CLASS} { - display: none; -} - /* Bring the resize handle inside the sheet instead of over its corner. */ .${NOTE_CLASS} .blocklyResizeHandle { transform: translate(-${BODY_INSET}px, -${BODY_INSET}px); @@ -255,7 +276,7 @@ Blockly.Css.register(` * text it heads. */ .${NOTE_CLASS}.blocklyComment .${TITLE_CLASS}.blocklyText { - fill: #000; + fill: var(--noteInkColour); font-size: ${TITLE_FONT_SIZE}px; font-weight: bold; } @@ -266,7 +287,8 @@ Blockly.Css.register(` */ .${NOTE_CLASS}.blocklyComment:not(.${TITLED_CLASS}) .${TITLE_CLASS}.blocklyText { - fill: #999; + fill: var(--noteInkColour); + opacity: 0.55; } .blocklyRTL .${TITLE_CLASS} { @@ -305,14 +327,18 @@ Blockly.Css.register(` */ .blocklyNoteTitleInput { background: transparent; - color: #000; text-align: left; padding: 0; } +/* + * Only the fade is set here. The colour itself is applied inline by + * title_editor.ts, because the editor lives in Blockly's WidgetDiv - a + * sibling of the workspace, not a descendant of the note - so the note's own + * --noteInkColour never reaches it. + */ .blocklyNoteTitleInput::placeholder { - color: #999; - opacity: 1; + opacity: 0.55; } .blocklyRTL .blocklyNoteTitleInput { diff --git a/blockly-workspace-notes/src/ui/icons.ts b/blockly-workspace-notes/src/ui/icons.ts new file mode 100644 index 0000000..c378e5a --- /dev/null +++ b/blockly-workspace-notes/src/ui/icons.ts @@ -0,0 +1,65 @@ +/** + * @fileoverview The glyphs a note draws, and how they are coloured. + * + * Two of the three are Blockly's own bar buttons, and core builds those as + * `` pointing at files in the host's media folder. An + * `` cannot be tinted — `fill` and `stroke` do not reach inside a + * referenced document — so a note could either keep core's buttons and accept + * their fixed navy, or draw its own and lose the keyboard navigation, focus + * handling and ARIA that come with `CommentBarButton`. + * + * It does neither. Core reads nothing off those elements but their bounding + * box, id and visibility, so the artwork can be replaced by rewriting `href` to + * a `data:` URI with the note's own ink colour already in it. The button stays + * core's; only the picture changes, and it changes again whenever the note is + * recoloured. + * + * The glyphs are Tabler's, all authored on a 24-unit grid at stroke-width 1 so + * the three read as one set. + */ + +/** Tabler's `chevron-down`, for the collapse button. */ +export const CHEVRON_GLYPH = ['M6 9l6 6l6 -6']; + +/** Tabler's `trash`, for the delete button. */ +export const TRASH_GLYPH = [ + 'M4 7l16 0', + 'M10 11l0 6', + 'M14 11l0 6', + 'M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12', + 'M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3', +]; + +/** + * Tabler's `pinned`, for the marker on a pinned note. + * + * Drawn inline rather than through a `data:` URI, because it is the plugin's + * own element rather than one of core's — see `model/note.ts`. + */ +export const PIN_GLYPH = [ + 'M9 4v6l-2 4v2h10v-2l-2 -4v-6', + 'M12 16l0 5', + 'M8 4l8 0', +]; + +/** + * Renders a glyph as a `data:` URI, stroked in the given colour. + * + * Percent-encoded rather than base64: it stays readable in the DOM inspector, + * which matters when the only way to check the colour followed the note is to + * look. `#` has to be encoded whatever else is left alone, since it would + * otherwise start the URI's fragment and truncate the colour. + * + * @param paths The glyph's path data, on a 24-unit grid. + * @param colour The stroke colour. + * @returns A `data:image/svg+xml` URI. + */ +export function glyphToDataUri(paths: string[], colour: string): string { + const svg = + `` + + paths.map((d) => ``).join('') + + ``; + return `data:image/svg+xml,${encodeURIComponent(svg)}`; +} diff --git a/blockly-workspace-notes/src/ui/title_editor.ts b/blockly-workspace-notes/src/ui/title_editor.ts index 85f3f4a..bb2e719 100644 --- a/blockly-workspace-notes/src/ui/title_editor.ts +++ b/blockly-workspace-notes/src/ui/title_editor.ts @@ -22,11 +22,12 @@ import * as Blockly from 'blockly/core'; import {TITLE_CLASS, UNTITLED_TITLE_TEXT} from '../constants/dom'; import { - NOTE_MARGIN, + BAR_TRAILING_INSET, TITLE_FONT_SIZE, TITLE_LINE_HEIGHT, } from '../constants/layout'; import type {Note} from '../model/note'; +import {inkFor} from '../utils/colour'; import {asOneUndoStep} from '../utils/undo'; /** @@ -60,11 +61,12 @@ function editorBox(note: Note): EditorBox | null { const scale = note.workspace.getAbsoluteScale(); // Room to type past the end of the current title, but never past the paper: // the editor is transparent, so anything overflowing would be text floating - // on the canvas. + // on the canvas. It stops at the delete button rather than the note's edge, + // for the same reason the title itself truncates there. const room = note.getSvgRoot().getBoundingClientRect().right - box.left - - NOTE_MARGIN * scale; + BAR_TRAILING_INSET * scale; return { left: box.left, top: box.top, @@ -143,6 +145,10 @@ export function editTitle(note: Note): void { // .blocklyNoteTitleInput. Left to CSS the heading would drop to book weight // the instant the caret landed in it. input.style.fontWeight = 'bold'; + // Inline for the same reason as the type above, and one of its own: the + // editor is in WidgetDiv, outside the note's SVG, so it inherits none of the + // note's colour custom properties. + input.style.color = inkFor(note.getColour()); input.value = note.getTitle(); div.appendChild(input); diff --git a/blockly-workspace-notes/src/utils/colour.ts b/blockly-workspace-notes/src/utils/colour.ts index f73e7ba..e04c789 100644 --- a/blockly-workspace-notes/src/utils/colour.ts +++ b/blockly-workspace-notes/src/utils/colour.ts @@ -14,6 +14,9 @@ import * as Blockly from 'blockly/core'; import { EDGE_VALUE_SCALE, + INK_SATURATION_MAX, + INK_SATURATION_SCALE, + INK_VALUE, NOTE_SATURATION, NOTE_VALUE, } from '../constants/colours'; @@ -68,6 +71,26 @@ export function edgeFor(colour: string): string { ); } +/** + * Derives the colour everything written on a note is drawn in. + * + * The same hue, pushed dark and saturated enough to read on both the note's + * body and its title bar. Used for the title, the body text and the glyphs in + * the bar, so a note is one colour throughout rather than a coloured card with + * black furniture on it. + * + * @param colour The note's colour, as a hex string. + * @returns The ink colour as hex. + */ +export function inkFor(colour: string): string { + const [hue, saturation] = hexToHsv(colour); + return Blockly.utils.colour.hsvToHex( + hue, + Math.min(saturation * INK_SATURATION_SCALE, INK_SATURATION_MAX), + INK_VALUE * 255, + ); +} + /** * Returns the note colour for a hue. * diff --git a/blockly-workspace-notes/test/colour.mocha.js b/blockly-workspace-notes/test/colour.mocha.js index 3c4b603..59da2a7 100644 --- a/blockly-workspace-notes/test/colour.mocha.js +++ b/blockly-workspace-notes/test/colour.mocha.js @@ -1,16 +1,16 @@ /** * @fileoverview Tests for the colours derived from a note's own colour. * - * A note stores one colour and the stylesheet reads two: the paper, and the - * edge that draws the card's outline and the rule under the title. The - * derivation is a pure function of a hex string, so it is checked here rather - * than through a workspace. + * A note stores one colour and the chrome reads three: the paper, the edge + * that draws the border and the title bar, and the ink everything written on + * the note is drawn in. Each is a pure function of a hex string, so they are + * checked here rather than through a workspace. */ import {assert} from 'chai'; import {DEFAULT_PALETTE} from '../src/index'; -import {colourForHue, edgeFor, hexToHsv} from '../src/utils/colour'; +import {colourForHue, edgeFor, hexToHsv, inkFor} from '../src/utils/colour'; /** * @param {string} hex A hex colour. @@ -92,3 +92,72 @@ suite('Note colours', function () { } }); }); + +/** + * @param {string} hex A hex colour. + * @returns {number} Its relative luminance, per WCAG. + */ +function luminance(hex) { + const channels = [1, 3, 5].map((i) => { + const c = parseInt(hex.slice(i, i + 2), 16) / 255; + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + }); + return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]; +} + +/** + * @param {string} a A hex colour. + * @param {string} b Another. + * @returns {number} Their WCAG contrast ratio. + */ +function contrast(a, b) { + const light = Math.max(luminance(a), luminance(b)); + const dark = Math.min(luminance(a), luminance(b)); + return (light + 0.05) / (dark + 0.05); +} + +suite('Note ink', function () { + test('it keeps the hue it came from', function () { + for (const {fill} of DEFAULT_PALETTE) { + // Grey has no hue to keep, so there is nothing to compare. + if (hexToHsv(fill)[1] === 0) continue; + assert.closeTo( + hexToHsv(inkFor(fill))[0], + hexToHsv(fill)[0], + 1, + `ink for ${fill} drifted off its hue`, + ); + } + }); + + test('it is far darker than the note it is written on', function () { + for (const {fill} of DEFAULT_PALETTE) { + assert.isBelow( + hexToHsv(inkFor(fill))[2], + hexToHsv(fill)[2] / 2, + `ink for ${fill} is not dark enough to read as ink`, + ); + } + }); + + // The guard that matters. The title sits on the bar and the body text on the + // paper, so the ink has to clear 4.5:1 against both - on every palette + // colour, and on any a host substitutes at the same saturation. Changing a + // palette entry or an ink constant without checking this is how a note ends + // up unreadable. + test('it is readable on both the paper and the bar', function () { + for (const {name, fill} of DEFAULT_PALETTE) { + const ink = inkFor(fill); + assert.isAtLeast( + contrast(ink, fill), + 4.5, + `${name}: ink on the note body is too faint`, + ); + assert.isAtLeast( + contrast(ink, edgeFor(fill)), + 4.5, + `${name}: ink on the title bar is too faint`, + ); + } + }); +}); From cc7f7dc94f2d0d4c7af9ec3d340274205255295a Mon Sep 17 00:00:00 2001 From: Preet Vadaliya Date: Wed, 9 Sep 2026 02:49:09 +0200 Subject: [PATCH 11/20] style: tighten the title bar and square up its buttons --- .../docs/images/note-anatomy.svg | 74 ++++---- .../docs/images/note-states.svg | 163 ++++++++---------- .../src/constants/layout.ts | 47 ++--- blockly-workspace-notes/src/model/note.ts | 9 +- blockly-workspace-notes/src/ui/css.ts | 10 ++ blockly-workspace-notes/src/ui/icons.ts | 7 +- .../src/ui/title_editor.ts | 4 +- 7 files changed, 156 insertions(+), 158 deletions(-) diff --git a/blockly-workspace-notes/docs/images/note-anatomy.svg b/blockly-workspace-notes/docs/images/note-anatomy.svg index da7de41..209cad3 100644 --- a/blockly-workspace-notes/docs/images/note-anatomy.svg +++ b/blockly-workspace-notes/docs/images/note-anatomy.svg @@ -1,24 +1,23 @@ - + The parts of a note A note at its default size of 260 by 180, with its title bar, collapse and delete buttons, title, body and resize handle labelled. - + - - + + - + - - + - - + @@ -26,50 +25,49 @@ - Shopping list + Shopping list - milk - bread - coffee + milk + bread + coffee - - + + - - - - - + + + + + - Collapse - folds the note to its bar + Collapse + folds the note to its bar - The card - square, thin border + The card + square, thin border - Delete - one undo brings it back + Delete + one undo brings it back - The body - written in the note's own ink + The body + written in the note's own ink - Resize handle - inside the sheet, not on its corner + Resize handle + inside the sheet, not on its corner - - - - + + + - the title bar, one step darker than the body + the title bar, one step darker than the body diff --git a/blockly-workspace-notes/docs/images/note-states.svg b/blockly-workspace-notes/docs/images/note-states.svg index 0cf798d..0c21f99 100644 --- a/blockly-workspace-notes/docs/images/note-states.svg +++ b/blockly-workspace-notes/docs/images/note-states.svg @@ -1,111 +1,98 @@ - + The states a note can be in - Five notes at their default size: a named note, one not named yet, a collapsed one, a pinned one showing its marker, and a selected one. + Five notes: a named note, one not named yet, a collapsed one, a pinned one showing its marker, and a selected one. - + - - Named - Not named yet - Collapsed - Pinned - Selected - - - the title is what you scan for - faded placeholders, both rows - the bar is the whole note - a pin in the bar, and a heavier edge - the ring follows the card - - - - - - - - + Named + the title is what you scan for + + + + + - - - - + + - Shopping list - milk + Shopping list + milk + bread + coffee - - - - - - + + Not named yet + faded placeholders, both rows + + + + + - - - - + + - Title - Say something... + Title + Say something... - - - - - + + Collapsed + the bar is the whole note + + + + - - - - + + - Release checklist + Release checklist - - - - - - + + Pinned + a pin in the bar, and a heavier edge + + + + + - - - - + + - - Do not move - Locked in place. + Do not move + Locked in place. - - - - - - - + + Selected + the ring follows the card + + + + + + - - - - + + - Ideas - Try a smaller step. + Ideas + Try a smaller step. + diff --git a/blockly-workspace-notes/src/constants/layout.ts b/blockly-workspace-notes/src/constants/layout.ts index 9e10ad0..c7b24c3 100644 --- a/blockly-workspace-notes/src/constants/layout.ts +++ b/blockly-workspace-notes/src/constants/layout.ts @@ -70,15 +70,13 @@ export const PIN_GLYPH_INK = {x: 7, y: 4, width: 10, height: 17}; * The margin on every side of a note, and the single number the rest of the * layout is built from. * - * This one deliberately steps outside Blockly's chrome scale, which tops out - * at `LARGE_PADDING` 10. Block chrome is packed tight because a block is an - * operator with as much crammed onto it as will fit; paper is the opposite, - * and margins are most of what makes a page read as one. It is set to - * `TITLE_LINE_HEIGHT` so the margin is exactly one line of text on every - * side - the oldest rule in page layout, and the reason the note reads as - * even rather than as merely roomy. + * A full line of text on every side reads as generous on a sheet of paper, but + * a note is not one any more - it is a titled card with a bar of controls, and + * at that scale the same margin leaves the bar looking inflated and the body + * indented. So it comes back to the top of Blockly's own chrome scale, which + * is what the bar's contents are sized against anyway. */ -export const NOTE_MARGIN = TITLE_LINE_HEIGHT; +export const NOTE_MARGIN = LARGE_PADDING; /** * Height of a note's title row: one line of text with a margin above and @@ -88,9 +86,14 @@ export const NOTE_MARGIN = TITLE_LINE_HEIGHT; export const TOPBAR_HEIGHT = TITLE_LINE_HEIGHT + NOTE_MARGIN * 2; /** - * The box each of core's bar buttons is drawn in. Core's own default, kept. + * The box each of core's bar buttons is drawn in. + * + * One line box, the same as the title beside it, so the glyphs and the heading + * share a cap height and the row reads as one line rather than as icons with + * text between them. Smaller than core's 20px, which was sized for a bar + * carrying nothing but icons. */ -export const BAR_ICON_SIZE = 20; +export const BAR_ICON_SIZE = TITLE_LINE_HEIGHT; /** The gap between a bar button and whatever it sits next to. */ export const BAR_ICON_GAP = MEDIUM_PADDING; @@ -106,24 +109,24 @@ export const BAR_ICON_GAP = MEDIUM_PADDING; export const BAR_ICON_MARGIN = (TOPBAR_HEIGHT - BAR_ICON_SIZE) / 2; /** - * Where the title row's contents begin, past the collapse button. + * How much of each end of the title row a button takes, gap included. * - * Core puts that button at `BAR_ICON_MARGIN` from the leading edge, so this is - * its far side plus a gap. The pin marker starts here when there is one, and - * the title follows it. + * One number for both ends, because the buttons are inset equally once the + * correction below is applied. The pin marker starts here when there is one, + * and the title follows it. */ -export const BAR_LEADING_INSET = BAR_ICON_MARGIN + BAR_ICON_SIZE + BAR_ICON_GAP; +export const BAR_INSET = BAR_ICON_MARGIN + BAR_ICON_SIZE + BAR_ICON_GAP; /** - * How much room the delete button needs at the trailing edge. + * How far the delete button moves to line up with the collapse button. * - * Core sets its x to the bar's width minus the button's size *including both - * margins*, so the icon's near edge is one full margin further in than the - * collapse button's - hence the doubled margin here rather than a single one. - * This is what the title's truncation has to stop short of. + * Core positions it at the bar's width minus the button's size *including both + * its margins*, while the collapse button gets a single margin from the + * leading edge. So the two sit at different insets - one margin further in on + * the right than on the left - which is plain to see once you look for it. + * Shifting it out by that extra margin makes the row symmetric. */ -export const BAR_TRAILING_INSET = - BAR_ICON_MARGIN * 2 + BAR_ICON_SIZE + BAR_ICON_GAP; +export const BAR_DELETE_NUDGE = BAR_ICON_MARGIN; /** Corner radius of a note's card; Blockly's own CORNER_RADIUS. */ export const FRAME_RADIUS = 8; diff --git a/blockly-workspace-notes/src/model/note.ts b/blockly-workspace-notes/src/model/note.ts index 0262113..b715796 100644 --- a/blockly-workspace-notes/src/model/note.ts +++ b/blockly-workspace-notes/src/model/note.ts @@ -19,8 +19,7 @@ import { UNTITLED_TITLE_TEXT, } from '../constants/dom'; import { - BAR_LEADING_INSET, - BAR_TRAILING_INSET, + BAR_INSET, MIN_SIZE, PIN_GLYPH_GRID, PIN_GLYPH_INK, @@ -318,7 +317,7 @@ export class Note extends RenderedNoteBase { const pinAdvance = this.isPinned() ? (PIN_GLYPH_INK.width * PIN_ICON_SIZE) / PIN_GLYPH_GRID + PIN_ICON_GAP : 0; - const leading = BAR_LEADING_INSET + pinAdvance; + const leading = BAR_INSET + pinAdvance; // Inside core's top bar group, which it mirrors in RTL - so x counts // inwards from the note's edge either way, and the sign follows. @@ -331,7 +330,7 @@ export class Note extends RenderedNoteBase { // Trim a character at a time; titles are short, so this settles fast. const maxWidth = Math.max( 0, - this.view.getSize().width - leading - BAR_TRAILING_INSET, + this.view.getSize().width - leading - BAR_INSET, ); let text = title; while ( @@ -358,7 +357,7 @@ export class Note extends RenderedNoteBase { const scale = PIN_ICON_SIZE / PIN_GLYPH_GRID; // Offset by the glyph's own padding so it is the ink that starts where the // title row's contents do, rather than the empty box around it. - const x = dir * (BAR_LEADING_INSET - PIN_GLYPH_INK.x * scale); + const x = dir * (BAR_INSET - PIN_GLYPH_INK.x * scale); const y = TOPBAR_HEIGHT / 2 - (PIN_GLYPH_INK.y + PIN_GLYPH_INK.height / 2) * scale; this.pin_.setAttribute( diff --git a/blockly-workspace-notes/src/ui/css.ts b/blockly-workspace-notes/src/ui/css.ts index 9753cd6..5b756fc 100644 --- a/blockly-workspace-notes/src/ui/css.ts +++ b/blockly-workspace-notes/src/ui/css.ts @@ -30,6 +30,7 @@ import { TITLE_CLASS, } from '../constants/dom'; import { + BAR_DELETE_NUDGE, BAR_ICON_MARGIN, BAR_ICON_SIZE, BODY_INSET, @@ -130,6 +131,15 @@ Blockly.Css.register(` ${BAR_ICON_MARGIN + BAR_ICON_SIZE / 2}px; } +/* + * Core insets this one by a doubled margin while the collapse button gets a + * single one, so without this the two ends of the bar do not match. See + * BAR_DELETE_NUDGE. + */ +.${NOTE_CLASS} .blocklyDeleteIcon { + transform: translateX(${BAR_DELETE_NUDGE}px); +} + /* * Core styles no focus ring for these, so a keyboard user would otherwise get * the browser's default outline on a bare . The note's own edge colour diff --git a/blockly-workspace-notes/src/ui/icons.ts b/blockly-workspace-notes/src/ui/icons.ts index c378e5a..9eec73d 100644 --- a/blockly-workspace-notes/src/ui/icons.ts +++ b/blockly-workspace-notes/src/ui/icons.ts @@ -14,8 +14,9 @@ * core's; only the picture changes, and it changes again whenever the note is * recoloured. * - * The glyphs are Tabler's, all authored on a 24-unit grid at stroke-width 1 so - * the three read as one set. + * The glyphs are Tabler's, all authored on a 24-unit grid at stroke-width 2 so + * the three read as one set. Scaled into a 16px box that lands at about 1.3px + * on screen - heavy enough to hold its own beside a bold heading. */ /** Tabler's `chevron-down`, for the collapse button. */ @@ -57,7 +58,7 @@ export const PIN_GLYPH = [ export function glyphToDataUri(paths: string[], colour: string): string { const svg = `` + paths.map((d) => ``).join('') + ``; diff --git a/blockly-workspace-notes/src/ui/title_editor.ts b/blockly-workspace-notes/src/ui/title_editor.ts index bb2e719..3305141 100644 --- a/blockly-workspace-notes/src/ui/title_editor.ts +++ b/blockly-workspace-notes/src/ui/title_editor.ts @@ -22,7 +22,7 @@ import * as Blockly from 'blockly/core'; import {TITLE_CLASS, UNTITLED_TITLE_TEXT} from '../constants/dom'; import { - BAR_TRAILING_INSET, + BAR_INSET, TITLE_FONT_SIZE, TITLE_LINE_HEIGHT, } from '../constants/layout'; @@ -66,7 +66,7 @@ function editorBox(note: Note): EditorBox | null { const room = note.getSvgRoot().getBoundingClientRect().right - box.left - - BAR_TRAILING_INSET * scale; + BAR_INSET * scale; return { left: box.left, top: box.top, From f79cb2861f5b5338d6f1a258f6b4b15609f0ebdf Mon Sep 17 00:00:00 2001 From: Preet Vadaliya Date: Wed, 9 Sep 2026 02:54:17 +0200 Subject: [PATCH 12/20] fix: even the body padding and the selection ring --- blockly-workspace-notes/src/constants/dom.ts | 3 ++ blockly-workspace-notes/src/model/note.ts | 31 +++++++++++++++-- blockly-workspace-notes/src/ui/css.ts | 36 ++++++++++++-------- 3 files changed, 54 insertions(+), 16 deletions(-) diff --git a/blockly-workspace-notes/src/constants/dom.ts b/blockly-workspace-notes/src/constants/dom.ts index cf9cbc6..0a24b1b 100644 --- a/blockly-workspace-notes/src/constants/dom.ts +++ b/blockly-workspace-notes/src/constants/dom.ts @@ -21,6 +21,9 @@ export const PIN_CLASS = 'blocklyNotePin'; /** CSS class of the SVG text element that renders a note's title. */ export const TITLE_CLASS = 'blocklyNoteTitle'; +/** CSS class of the rect that draws the selection ring. */ +export const SELECTION_CLASS = 'blocklyNoteSelection'; + /** * Shown in the title row of a note that has not been named yet. * diff --git a/blockly-workspace-notes/src/model/note.ts b/blockly-workspace-notes/src/model/note.ts index b715796..f53a1b5 100644 --- a/blockly-workspace-notes/src/model/note.ts +++ b/blockly-workspace-notes/src/model/note.ts @@ -13,6 +13,7 @@ import * as Blockly from 'blockly/core'; import { NOTE_CLASS, PIN_CLASS, + SELECTION_CLASS, PINNED_CLASS, TITLED_CLASS, TITLE_CLASS, @@ -62,6 +63,9 @@ export class Note extends RenderedNoteBase { /** The marker shown while the note is pinned. */ private pin_?: SVGGElement; + /** The rect that draws the selection ring. */ + private selection_?: SVGRectElement; + /** * Core's two bar buttons. * @@ -130,6 +134,22 @@ export class Note extends RenderedNoteBase { } else { root.appendChild(this.pin_); } + + /** + * The selection ring. + * + * A rect of its own rather than a stroke on the card, because the card is + * painted first and the title bar is painted over it: a stroke there is + * half-covered for the bar's whole height and full thickness below, which + * reads as a ring that changes width halfway down. Drawn last, it is even + * the whole way round. + * @private + */ + this.selection_ = Blockly.utils.dom.createSvgElement( + Blockly.utils.Svg.RECT, + {'class': SELECTION_CLASS, 'aria-hidden': 'true'}, + root, + ); for (const d of PIN_GLYPH) { Blockly.utils.dom.createSvgElement( Blockly.utils.Svg.PATH, @@ -256,8 +276,15 @@ export class Note extends RenderedNoteBase { * collapse-aware measurement, so the height is taken from that instead. */ renderCard() { - if (!this.card_) return; - this.card_.setAttribute('height', `${this.view.getSize().height}`); + const {width, height} = this.view.getSize(); + this.card_?.setAttribute('height', `${height}`); + + // The ring traces the card, so it takes the same box - including core's + // convention of hanging the card off the leading edge in RTL. + this.selection_?.setAttribute('x', `${this.workspace.RTL ? -width : 0}`); + this.selection_?.setAttribute('y', '0'); + this.selection_?.setAttribute('width', `${width}`); + this.selection_?.setAttribute('height', `${height}`); } /** diff --git a/blockly-workspace-notes/src/ui/css.ts b/blockly-workspace-notes/src/ui/css.ts index 5b756fc..08121a8 100644 --- a/blockly-workspace-notes/src/ui/css.ts +++ b/blockly-workspace-notes/src/ui/css.ts @@ -25,6 +25,7 @@ import * as Blockly from 'blockly/core'; import { NOTE_CLASS, PIN_CLASS, + SELECTION_CLASS, PINNED_CLASS, TITLED_CLASS, TITLE_CLASS, @@ -164,7 +165,7 @@ Blockly.Css.register(` */ .${NOTE_CLASS} .blocklyMinimalBody { box-sizing: border-box; - padding: 0 ${BODY_INSET}px ${BODY_INSET}px; + padding: ${BODY_INSET}px; } /* @@ -363,24 +364,31 @@ Blockly.Css.register(` /* * Selection, last in this sheet and over-specific on purpose. * - * Core's ring is .blocklySelected .blocklyCommentHighlight - two classes, - * exactly what the card rule above is - and this stylesheet is registered - * after core's, so without a third class the card's own hairline would - * quietly win and a selected note would show no ring at all. + * Core strokes its own highlight rect, which for a note is the card - painted + * before the title bar, so the bar covers the ring's inner half for its whole + * height and leaves it full thickness below. The ring gets its own rect, + * painted last, instead. Core's rules are switched off rather than overridden, + * both the expanded one and the collapsed pair that moves the ring onto the + * bar. * - * The collapsed selectors undo core's own pair, which drops the ring from the - * highlight rect and moves it onto the top bar. That is right for a comment - * whose bar is its whole collapsed body; here the card is still the shape to - * outline, and the bar has no fill to carry a stroke. + * #fc3 is Blockly's selection colour, not the note's, and stays fixed: a + * selected note should look selected the same way a selected block does. */ .blocklySelected.${NOTE_CLASS} .blocklyCommentHighlight, -.blocklySelected.${NOTE_CLASS}.blocklyCollapsed .blocklyCommentHighlight { - stroke: #fc3; - stroke-width: 3px; -} - +.blocklySelected.${NOTE_CLASS}.blocklyCollapsed .blocklyCommentHighlight, .blocklySelected.${NOTE_CLASS}.blocklyCollapsed .blocklyCommentTopbarBackground { stroke: none; } + +.${NOTE_CLASS} .${SELECTION_CLASS} { + fill: none; + stroke: none; + pointer-events: none; +} + +.blocklySelected.${NOTE_CLASS} .${SELECTION_CLASS} { + stroke: #fc3; + stroke-width: 3px; +} `); From 143243a0b92e7d64db5019ecc0c17d7216137f06 Mon Sep 17 00:00:00 2001 From: Preet Vadaliya Date: Wed, 9 Sep 2026 02:59:58 +0200 Subject: [PATCH 13/20] docs: bring prose and comments in line with the redesign --- blockly-workspace-notes/README.md | 2 +- blockly-workspace-notes/docs/design.md | 2 +- blockly-workspace-notes/docs/using-notes.md | 4 +-- .../src/constants/layout.ts | 29 +++++++-------- blockly-workspace-notes/src/model/note.ts | 23 ++++++------ blockly-workspace-notes/src/ui/css.ts | 36 ++++++++++--------- blockly-workspace-notes/src/utils/colour.ts | 13 ++++--- 7 files changed, 54 insertions(+), 55 deletions(-) diff --git a/blockly-workspace-notes/README.md b/blockly-workspace-notes/README.md index e3b9502..04bbb4f 100644 --- a/blockly-workspace-notes/README.md +++ b/blockly-workspace-notes/README.md @@ -2,7 +2,7 @@ [![Built on Blockly](https://tinyurl.com/built-on-blockly)](https://github.com/google/blockly) -Sticky notes for a Blockly workspace: draggable, resizable, colour-coded paper +Sticky notes for a Blockly workspace: draggable, resizable, colour-coded cards with a title, an author and a stacking order.

diff --git a/blockly-workspace-notes/docs/design.md b/blockly-workspace-notes/docs/design.md index 53f3010..541e473 100644 --- a/blockly-workspace-notes/docs/design.md +++ b/blockly-workspace-notes/docs/design.md @@ -65,7 +65,7 @@ serif prompt box, which matches nothing else on the page. Using Blockly's field editor was only half the fix, because a field editor is built to be _seen_ — a white box with the text selected — and on a note that -still read as a mode opening on the paper. The editor is now invisible: same +still read as a mode opening on the note. The editor is now invisible: same position, same font, same placeholder, no box, no selection. The title should behave exactly like the body, and the body has never needed anything to open. diff --git a/blockly-workspace-notes/docs/using-notes.md b/blockly-workspace-notes/docs/using-notes.md index d54a405..b8344b5 100644 --- a/blockly-workspace-notes/docs/using-notes.md +++ b/blockly-workspace-notes/docs/using-notes.md @@ -1,7 +1,7 @@ # Using notes -A note is a sheet of paper on the workspace. It has a title you can read at a -glance, a colour, and room to write. +A note is a card on the workspace: a title bar you can read at a glance, a +colour of its own, and room to write underneath.

The parts of a note: title bar, collapse and delete buttons, title, body and resize handle diff --git a/blockly-workspace-notes/src/constants/layout.ts b/blockly-workspace-notes/src/constants/layout.ts index c7b24c3..db53428 100644 --- a/blockly-workspace-notes/src/constants/layout.ts +++ b/blockly-workspace-notes/src/constants/layout.ts @@ -128,9 +128,6 @@ export const BAR_INSET = BAR_ICON_MARGIN + BAR_ICON_SIZE + BAR_ICON_GAP; */ export const BAR_DELETE_NUDGE = BAR_ICON_MARGIN; -/** Corner radius of a note's card; Blockly's own CORNER_RADIUS. */ -export const FRAME_RADIUS = 8; - /** * Width of the body's scrollbar, for engines styled through * ::-webkit-scrollbar. Half of `SMALL_PADDING` either side of a 2px thumb - @@ -153,20 +150,20 @@ export const DEFAULT_SIZE = {width: 260, height: 180}; /** * The smallest a note can be resized to. * - * Core has a floor of its own, but it is not one a note can use. The width it - * enforces is the width of the truncated preview text - which a note hides, - * since the title stands in for it - so on a note with no body text the floor - * is zero, and the resize handle drags the paper away to nothing. What is left - * has no surface to grab and no title to read: the note is still there, still - * saved, and only undo brings it back. The height it enforces is the top bar - * plus 20px, which was measured for core's 24px bar and leaves a note less - * than one line of writing under its own title row. + * Core enforces a floor of its own and the larger of the two always wins, so + * this is not the whole story - but it is the half that bites when core's is + * too low. Core's width is the truncated body preview plus whichever bar + * buttons are showing, which on a note with no text yet comes to just the two + * buttons; its height is the bar plus 20px, which leaves less than one line of + * writing under the title. Either would let the resize handle drag a note down + * to something with no surface to grab and no title to read - still there, + * still saved, and findable only by undo. * - * So a note sets its own, and states it in the terms the rest of the layout - * is built from: a note is never smaller than its title row plus one line of - * body and the margin under it, and never narrower than a title of a few - * characters between its two margins. Anything smaller is not a small note, - * it is a lost one. + * So a note states its own in the terms the rest of the layout is built from: + * never shorter than its title bar plus one line of body and the margin under + * it, and never narrower than a title of a few characters between its two + * margins. Measured, that means this floor governs an empty note while core's + * takes over once there is enough body text to push past it. */ export const MIN_SIZE = { width: NOTE_MARGIN * 2 + TITLE_LINE_HEIGHT * 4, diff --git a/blockly-workspace-notes/src/model/note.ts b/blockly-workspace-notes/src/model/note.ts index f53a1b5..fbd46c0 100644 --- a/blockly-workspace-notes/src/model/note.ts +++ b/blockly-workspace-notes/src/model/note.ts @@ -243,15 +243,15 @@ export class Note extends RenderedNoteBase { // writing area's offset there from the measured height of the top bar // rect. That measurement happens before `super()` returns, which is // before this constructor can add NOTE_CLASS - so the rect is still core's - // own 24px bar, and the body is left starting a title row too high, - // overlapping the title and crossing the rule. It corrected itself on the - // first resize, collapse or keystroke, which is what made it look like a - // rendering glitch rather than a wrong number. + // own 24px bar, and the body is left starting too high, overlapping the + // title row. It corrected itself on the first resize, collapse or + // keystroke, which is what made it look like a rendering glitch rather + // than a wrong number. // - // The class is on the root by now, so one more size pass measures 48 and - // puts the body under the rule. Without firing events: the note is still - // being constructed, and a size change nobody made does not belong on the - // undo stack. + // The class is on the root by now, so one more size pass measures the + // note's own bar height and puts the body below it. Without firing events: + // the note is still being constructed, and a size change nobody made does + // not belong on the undo stack. this.view.setSizeWithoutFiringEvents(this.view.getSize()); } @@ -291,9 +291,10 @@ export class Note extends RenderedNoteBase { * Paints the note by overriding the CSS custom properties core's comment * stylesheet already reads, which avoids restyling its elements directly. * - * Two values off the one stored colour: the paper, and the edge that draws - * both the card's outline and the rule under the title. The text is written - * straight onto the paper, so there is no third. + * Three values off the one stored colour: the body, the edge that draws both + * the border and the title bar, and the ink that everything written on the + * note is drawn in. The ink is the one core has no property for, so it also + * has to be painted into the bar buttons by hand - see below. */ renderColour() { const colour = this.getColour(); diff --git a/blockly-workspace-notes/src/ui/css.ts b/blockly-workspace-notes/src/ui/css.ts index 08121a8..7deca92 100644 --- a/blockly-workspace-notes/src/ui/css.ts +++ b/blockly-workspace-notes/src/ui/css.ts @@ -5,14 +5,16 @@ * injections that happen afterwards, so importing this plugin before calling * Blockly.inject is required. * - * A note is a plain rounded card. Almost everything here is a small - * adjustment to elements core already builds and already sizes - the card is - * core's own highlight rect, and the writing area is core's own textarea - so - * that resizing, collapsing and selection keep working without help. + * A note is a Blockly comment, so it is shaped like one: square, thin + * bordered, with a title bar carrying its two controls. Almost everything here + * is a small adjustment to elements core already builds and already sizes - + * the card is core's own highlight rect, the bar and its buttons are core's + * own, and the writing area is core's own textarea - so that resizing, + * collapsing, selection and keyboard access keep working without help. * * Colours come from the --commentFillColour / --commentBorderColour custom - * properties core's comment stylesheet reads, plus one of ours for the - * writing area's fill. + * properties core's comment stylesheet already reads, plus --noteInkColour of + * ours for everything written on the note. * * NOTE: this stylesheet is a JavaScript template literal. A backtick anywhere * inside it, including in a comment quoting a selector, silently ends the @@ -42,9 +44,9 @@ import { Blockly.Css.register(` /* - * The sheet of paper. This is core's own highlight rect, which it resizes on - * every pointer move of a drag and strokes when the note is selected; the - * rounded corners are set as attributes by the note itself. + * The card. This is core's own highlight rect, which it resizes on every + * pointer move of a drag; a note fills it, where a plain comment leaves it + * empty. Left square, as core draws it. */ .${NOTE_CLASS} .blocklyCommentHighlight { fill: var(--commentFillColour); @@ -154,14 +156,14 @@ Blockly.Css.register(` } /* - * You write on the paper, not in a box on it. + * You write on the card, not in a box on it. * * Core gives its textarea a fill and a 1px border, which is what makes a * comment read as a panel - and a lighter bordered panel inset in a coloured * body is precisely how Blockly draws a field on a block. Both come off here, - * so the note stays one flat colour and only the rule under the title divides - * it. Core's own 5px padding goes too, so the body's first character lines up - * with the first letter of the title rather than sitting 5px right of it. + * so the body is one flat colour and the bar above is what divides it. Core's + * own 5px padding goes too, replaced by an even inset on all four sides so the + * text sits the same distance off the bar as off the edges. */ .${NOTE_CLASS} .blocklyMinimalBody { box-sizing: border-box; @@ -174,16 +176,16 @@ Blockly.Css.register(` * * Core's textarea scrolls with the platform's own bar, and on a system set to * show scrollbars always - rather than as an overlay that fades - that is a - * full-width white track down the side of the sheet: the one piece of - * furniture on a note that otherwise carries nothing but the words on it. + * full-width white track down the side of the card: a piece of furniture the + * note never asked for, on the one surface meant to hold nothing but words. * * So it is made quiet rather than removed. Removing it outright would take the * only sign that there is more text below, on the one element of a note that * can have more to show than fits. Instead the gutter is narrowed and always * reserved - the text keeps its width whether the bar is painted or not, so * nothing reflows - and the paint is what changes: nothing at rest, and while - * the pointer is on the note or the caret is in it, a thumb in the paper's own - * edge colour, the same hairline that draws the card and the rule. + * the pointer is on the note or the caret is in it, a thumb in the note's own + * edge colour, the same shade that draws the border and the title bar. */ .${NOTE_CLASS} .blocklyTextarea { background-color: transparent; diff --git a/blockly-workspace-notes/src/utils/colour.ts b/blockly-workspace-notes/src/utils/colour.ts index e04c789..c261961 100644 --- a/blockly-workspace-notes/src/utils/colour.ts +++ b/blockly-workspace-notes/src/utils/colour.ts @@ -5,9 +5,9 @@ * block's is, but a pale one — where Blockly's `hueToHex` is S 0.45 at V 0.65 * and carries a white label, a note is a light wash carrying black text. * - * One stored colour paints the whole card; the text is written straight onto - * it. The only value read off it is the edge - the same hue a step down in - * value - which draws the card's outline and the hairline under the title. + * One stored colour paints the body. Two values are read off it: the edge, a + * step down in value, which draws the border and the title bar; and the ink, + * darker still, which draws everything written on the note. */ import * as Blockly from 'blockly/core'; @@ -54,10 +54,9 @@ export function hexToHsv(hex: string): [number, number, number] { /** * Derives the shade a note's edges are drawn in. * - * The same hue a step down in value. It draws the card's hairline and the - * border around the writing area — which is exactly what Blockly's own - * comment does, since core's `.blocklyTextarea` rule already reads this from - * `--commentBorderColour`. + * The same hue a step down in value. It draws the note's border and its title + * bar — which is exactly what Blockly's own comment does, since core paints + * `.blocklyCommentTopbarBackground` from `--commentBorderColour` already. * * @param colour The note's colour, as a hex string. * @returns The edge colour as hex. From dc69964e20568a759c90bf263e3e55c2d581323a Mon Sep 17 00:00:00 2001 From: Preet Vadaliya Date: Wed, 9 Sep 2026 03:20:38 +0200 Subject: [PATCH 14/20] feat: show a note's author and last-changed date --- blockly-workspace-notes/README.md | 2 +- .../docs/getting-started.md | 2 +- .../docs/images/note-anatomy.svg | 26 ++- .../docs/images/note-states.svg | 130 +++++++------ blockly-workspace-notes/docs/saving.md | 22 +-- blockly-workspace-notes/docs/using-notes.md | 17 +- blockly-workspace-notes/src/constants/dom.ts | 6 + .../src/constants/layout.ts | 39 ++++ blockly-workspace-notes/src/model/note.ts | 181 ++++++++++++++++++ .../src/model/note_mixin.ts | 20 ++ blockly-workspace-notes/src/ui/css.ts | 53 +++++ blockly-workspace-notes/src/ui/icons.ts | 15 ++ blockly-workspace-notes/test/note.mocha.js | 39 ++++ 13 files changed, 476 insertions(+), 76 deletions(-) diff --git a/blockly-workspace-notes/README.md b/blockly-workspace-notes/README.md index 04bbb4f..69ed311 100644 --- a/blockly-workspace-notes/README.md +++ b/blockly-workspace-notes/README.md @@ -6,7 +6,7 @@ Sticky notes for a Blockly workspace: draggable, resizable, colour-coded cards with a title, an author and a stacking order.

- The parts of a note: title bar, collapse and delete buttons, title, body and resize handle + The parts of a note: title bar, collapse and delete buttons, title, body, author and date, and resize handle

Notes extend Blockly's own workspace comments, so dragging, resizing, diff --git a/blockly-workspace-notes/docs/getting-started.md b/blockly-workspace-notes/docs/getting-started.md index 351c4ec..dd9ffa6 100644 --- a/blockly-workspace-notes/docs/getting-started.md +++ b/blockly-workspace-notes/docs/getting-started.md @@ -42,7 +42,7 @@ new WorkspaceNotes(workspace, { | ---------------------------- | -------------------- | ----------------------------------------------- | | `palette` | 7 stationery colours | The swatches offered in the Colour menu | | `defaultSize` | `260 × 180` | The size a new note is created at | -| `getAuthor` | `() => ''` | Called once per note to record who made it | +| `getAuthor` | `() => ''` | Names the note's author, shown along its foot | | `contextMenu` | `true` | Adds the note items to the right-click menu | | `xmlSupport` | `true` | Keeps notes intact through the older XML format | | `skipSerializerRegistration` | `false` | Leaves saving and loading entirely to your app | diff --git a/blockly-workspace-notes/docs/images/note-anatomy.svg b/blockly-workspace-notes/docs/images/note-anatomy.svg index 209cad3..76249d2 100644 --- a/blockly-workspace-notes/docs/images/note-anatomy.svg +++ b/blockly-workspace-notes/docs/images/note-anatomy.svg @@ -33,6 +33,26 @@ coffee + + + + + + + + ada + + + + + + + + 9 Sep 2026 + @@ -41,7 +61,7 @@ - + @@ -51,8 +71,8 @@ Collapse folds the note to its bar - The card - square, thin border + Author and date + recorded automatically Delete one undo brings it back diff --git a/blockly-workspace-notes/docs/images/note-states.svg b/blockly-workspace-notes/docs/images/note-states.svg index 0c21f99..0cedfde 100644 --- a/blockly-workspace-notes/docs/images/note-states.svg +++ b/blockly-workspace-notes/docs/images/note-states.svg @@ -1,43 +1,50 @@ - + The states a note can be in Five notes: a named note, one not named yet, a collapsed one, a pinned one showing its marker, and a selected one. - + Named the title is what you scan for - + - - - - - - + + Shopping list milk bread - coffee + + ada + + 9 Sep 2026 Not named yet faded placeholders, both rows - + - - - - - - + + Title Say something... + + 9 Sep 2026 Collapsed @@ -45,54 +52,63 @@ - - - - - - + + Release checklist - Pinned - a pin in the bar, and a heavier edge - - + Pinned + a pin in the bar, and a heavier edge + + - - - - - - + + - - + stroke="#4c171f" stroke-width="2" stroke-linecap="round" + stroke-linejoin="round"> Do not move Locked in place. + + ada + + 9 Sep 2026 - Selected - the ring follows the card - - + Selected + the ring follows the card + + - - - - - - - + + Ideas Try a smaller step. + + ada + + 9 Sep 2026 + diff --git a/blockly-workspace-notes/docs/saving.md b/blockly-workspace-notes/docs/saving.md index 9a02bd4..baf524e 100644 --- a/blockly-workspace-notes/docs/saving.md +++ b/blockly-workspace-notes/docs/saving.md @@ -41,17 +41,17 @@ Notes get their own key in the file, beside `blocks`: Everything that gets remembered: -| Saved | Notes | -| --------------------- | -------------------------------------------------- | -| Position | Exact, including right-to-left layouts | -| Width and height | Exactly as you left them | -| The text | | -| The title | Only if it has one | -| The colour | Only if it is not the default | -| Pinned | Only if pinned | -| Stacking order | So overlapping notes come back in the same order | -| Collapsed | Only if collapsed | -| Author and timestamps | Author comes from `getAuthor`; dates are automatic | +| Saved | Notes | +| --------------------- | -------------------------------------------------------------------------------------------------------- | +| Position | Exact, including right-to-left layouts | +| Width and height | Exactly as you left them | +| The text | | +| The title | Only if it has one | +| The colour | Only if it is not the default | +| Pinned | Only if pinned | +| Stacking order | So overlapping notes come back in the same order | +| Collapsed | Only if collapsed | +| Author and timestamps | Author comes from `getAuthor`; dates are automatic, and `updatedAt` moves on any edit including the body | ## Three things about the format diff --git a/blockly-workspace-notes/docs/using-notes.md b/blockly-workspace-notes/docs/using-notes.md index b8344b5..7efe62b 100644 --- a/blockly-workspace-notes/docs/using-notes.md +++ b/blockly-workspace-notes/docs/using-notes.md @@ -4,12 +4,13 @@ A note is a card on the workspace: a title bar you can read at a glance, a colour of its own, and room to write underneath.

- The parts of a note: title bar, collapse and delete buttons, title, body and resize handle + The parts of a note: title bar, collapse and delete buttons, title, body, author and date, and resize handle

The title bar carries two buttons — collapse on the left, delete on the right — -and a pin marker appears between them when a note is pinned. Everything else a -note can do is in the right-click menu. +and a pin marker appears between them when a note is pinned. Along the foot, +a small line records who wrote the note and when it last changed. Everything +else a note can do is in the right-click menu. ## Add one @@ -57,6 +58,16 @@ turns to point right. Press it again to open the note back up. This is how you keep a long note around without it covering your blocks. +## Who wrote it, and when + +Along the bottom of every note is a quiet line showing its author and the date +it last changed. Both are recorded automatically — the date every time you +edit, and the author from the `getAuthor` option your app supplies. Without +that option there is no name to show, so the line falls back to the date alone. + +On a narrow note the name gives way first, since the date is short and the name +can be cut to nothing useful. A collapsed note hides the line with its body. + ## Order them Right-click → **Bring to front** or **Send to back**, for when notes overlap. diff --git a/blockly-workspace-notes/src/constants/dom.ts b/blockly-workspace-notes/src/constants/dom.ts index 0a24b1b..5d6b4d3 100644 --- a/blockly-workspace-notes/src/constants/dom.ts +++ b/blockly-workspace-notes/src/constants/dom.ts @@ -21,6 +21,12 @@ export const PIN_CLASS = 'blocklyNotePin'; /** CSS class of the SVG text element that renders a note's title. */ export const TITLE_CLASS = 'blocklyNoteTitle'; +/** CSS class of the group holding the author and date along the note's foot. */ +export const FOOTER_CLASS = 'blocklyNoteFooter'; + +/** CSS class of the two text runs inside the footer. */ +export const FOOTER_TEXT_CLASS = 'blocklyNoteFooterText'; + /** CSS class of the rect that draws the selection ring. */ export const SELECTION_CLASS = 'blocklyNoteSelection'; diff --git a/blockly-workspace-notes/src/constants/layout.ts b/blockly-workspace-notes/src/constants/layout.ts index db53428..d562fc3 100644 --- a/blockly-workspace-notes/src/constants/layout.ts +++ b/blockly-workspace-notes/src/constants/layout.ts @@ -147,6 +147,45 @@ export const BODY_INSET = NOTE_MARGIN; */ export const DEFAULT_SIZE = {width: 260, height: 180}; +/** + * The strip along the foot of a note carrying its author and date. + * + * One line box again, so the note has the same rhythm top and bottom. The body + * gives up this much height for it - see the padding in `ui/css.ts` - which is + * why it is reserved as a constant rather than drawn wherever it lands. + */ +export const FOOTER_HEIGHT = TITLE_LINE_HEIGHT; + +/** The glyphs in the footer, smaller than the bar's since the text is too. */ +export const FOOTER_ICON_SIZE = 12; + +/** Footer type size: small enough to read as a caption, not as content. */ +export const FOOTER_FONT_SIZE = 11; + +/** Between a footer glyph and the text it labels. */ +export const FOOTER_LABEL_GAP = SMALL_PADDING; + +/** + * The shortest an author's name is worth showing. + * + * Truncation is what gives the date room on a narrow note, but a name cut to + * one or two letters conveys nothing while still spending a glyph and a gap on + * saying so. Below this the author steps aside entirely. + */ +export const MIN_FOOTER_AUTHOR_CHARS = 3; + +/** Between the author and the date. */ +export const FOOTER_ITEM_GAP = LARGE_PADDING; + +/** + * How much of the footer's trailing end the resize handle claims. + * + * Core's handle is 12px and the stylesheet pulls it a body inset in from the + * corner, which puts it squarely in the footer's row. The footer stops short of + * it rather than running underneath. + */ +export const FOOTER_HANDLE_CLEARANCE = 12 + LARGE_PADDING; + /** * The smallest a note can be resized to. * diff --git a/blockly-workspace-notes/src/model/note.ts b/blockly-workspace-notes/src/model/note.ts index fbd46c0..e20ab1b 100644 --- a/blockly-workspace-notes/src/model/note.ts +++ b/blockly-workspace-notes/src/model/note.ts @@ -12,6 +12,8 @@ import * as Blockly from 'blockly/core'; import { NOTE_CLASS, + FOOTER_CLASS, + FOOTER_TEXT_CLASS, PIN_CLASS, SELECTION_CLASS, PINNED_CLASS, @@ -21,6 +23,13 @@ import { } from '../constants/dom'; import { BAR_INSET, + BODY_INSET, + FOOTER_HANDLE_CLEARANCE, + FOOTER_HEIGHT, + FOOTER_ICON_SIZE, + FOOTER_ITEM_GAP, + FOOTER_LABEL_GAP, + MIN_FOOTER_AUTHOR_CHARS, MIN_SIZE, PIN_GLYPH_GRID, PIN_GLYPH_INK, @@ -31,9 +40,11 @@ import { import type {NoteCopyData} from '../types/clipboard'; import {edgeFor, inkFor} from '../utils/colour'; import { + CALENDAR_GLYPH, CHEVRON_GLYPH, PIN_GLYPH, TRASH_GLYPH, + USER_GLYPH, glyphToDataUri, } from '../ui/icons'; import {editTitle} from '../ui/title_editor'; @@ -66,6 +77,21 @@ export class Note extends RenderedNoteBase { /** The rect that draws the selection ring. */ private selection_?: SVGRectElement; + /** The footer group, and the four pieces laid out inside it. */ + private footer_?: SVGGElement; + + /** See `footer_`. */ + private authorIcon_?: SVGGElement; + + /** See `footer_`. */ + private authorText_?: SVGTextElement; + + /** See `footer_`. */ + private dateIcon_?: SVGGElement; + + /** See `footer_`. */ + private dateText_?: SVGTextElement; + /** * Core's two bar buttons. * @@ -135,6 +161,42 @@ export class Note extends RenderedNoteBase { root.appendChild(this.pin_); } + /** + * The footer: who wrote the note, and when it last changed. + * + * Both are recorded on every note already and neither was ever shown. It + * sits along the foot rather than in the title bar because the bar is a + * row of controls and this is a caption - and because the body can give up + * a line at the bottom, where the bar has no room to give. + * @private + */ + this.footer_ = Blockly.utils.dom.createSvgElement( + Blockly.utils.Svg.G, + {'class': FOOTER_CLASS, 'aria-hidden': 'true'}, + root, + ); + const glyph = (paths: string[]) => { + const g = Blockly.utils.dom.createSvgElement( + Blockly.utils.Svg.G, + {}, + this.footer_, + ); + for (const d of paths) { + Blockly.utils.dom.createSvgElement(Blockly.utils.Svg.PATH, {'d': d}, g); + } + return g; + }; + const label = () => + Blockly.utils.dom.createSvgElement( + Blockly.utils.Svg.TEXT, + {'class': FOOTER_TEXT_CLASS}, + this.footer_, + ); + this.authorIcon_ = glyph(USER_GLYPH); + this.authorText_ = label(); + this.dateIcon_ = glyph(CALENDAR_GLYPH); + this.dateText_ = label(); + /** * The selection ring. * @@ -263,6 +325,7 @@ export class Note extends RenderedNoteBase { if (this.isDeadOrDying()) return; this.renderCard(); this.renderTitle(); + this.renderFooter(); } /** @@ -370,6 +433,102 @@ export class Note extends RenderedNoteBase { } } + /** + * Lays out the author and the date along the foot of the note. + * + * Both are read from the note's own metadata, so a host that supplies no + * `getAuthor` gets the date alone and the row shortens to match. The date is + * `updatedAt` rather than `createdAt`: on a working note the useful question + * is whether it is still current. + * + * The row stops short of the resize handle, which core puts in this same + * corner, and the author is what gives way when there is not enough width - + * the date is short and fixed, so truncating it would save nothing. + */ + renderFooter() { + if (!this.footer_ || !this.authorText_ || !this.dateText_) return; + if (!this.authorIcon_ || !this.dateIcon_) return; + + const {author, updatedAt} = this.getMeta(); + const date = formatDate(updatedAt); + // Nothing worth a row: let the stylesheet's :empty rule hide it. + this.footer_.setAttribute('data-empty', author || date ? 'false' : 'true'); + + const dir = this.workspace.RTL ? -1 : 1; + const {width, height} = this.view.getSize(); + const y = height - BODY_INSET - FOOTER_HEIGHT / 2; + const scale = FOOTER_ICON_SIZE / PIN_GLYPH_GRID; + + // `offset` is measured from the note's leading edge. The footer sits in + // the root group, which core does not mirror - unlike the title bar - so + // nothing here flips itself. In RTL the local space runs from -width to 0, + // so an offset becomes a negative coordinate and the glyph is placed by + // its far edge; the artwork keeps its own orientation either way, which a + // calendar or a person very much needs. + const place = ( + icon: SVGGElement, + text: SVGTextElement, + value: string, + offset: number, + ) => { + const shown = !!value; + icon.style.display = shown ? '' : 'none'; + text.style.display = shown ? '' : 'none'; + if (!shown) return 0; + const iconX = dir > 0 ? offset : -(offset + FOOTER_ICON_SIZE); + icon.setAttribute( + 'transform', + `translate(${iconX}, ${y - FOOTER_ICON_SIZE / 2}) scale(${scale})`, + ); + const textOffset = offset + FOOTER_ICON_SIZE + FOOTER_LABEL_GAP; + text.setAttribute('x', `${dir * textOffset}`); + text.setAttribute('y', `${y}`); + text.textContent = value; + return ( + FOOTER_ICON_SIZE + + FOOTER_LABEL_GAP + + Blockly.utils.dom.getTextWidth(text) + ); + }; + + // The date is placed first so its width is known, then the author is given + // whatever is left. + const room = Math.max(0, width - BODY_INSET * 2 - FOOTER_HANDLE_CLEARANCE); + this.dateText_.textContent = date; + const dateWidth = date + ? FOOTER_ICON_SIZE + + FOOTER_LABEL_GAP + + Blockly.utils.dom.getTextWidth(this.dateText_) + : 0; + + let authorLabel = author; + const authorRoom = + room - dateWidth - (date && author ? FOOTER_ITEM_GAP : 0); + if (authorLabel) { + let trimmed = author; + this.authorText_.textContent = authorLabel; + while ( + trimmed.length > 1 && + FOOTER_ICON_SIZE + + FOOTER_LABEL_GAP + + Blockly.utils.dom.getTextWidth(this.authorText_) > + authorRoom + ) { + trimmed = trimmed.slice(0, -1); + authorLabel = `${trimmed}\u2026`; + this.authorText_.textContent = authorLabel; + } + // A name cut to a letter or two says nothing and still spends a glyph + // and a gap saying it, so below that the author gives up its place. + if (trimmed.length < MIN_FOOTER_AUTHOR_CHARS) authorLabel = ''; + } + + let x = BODY_INSET; + const used = place(this.authorIcon_, this.authorText_, authorLabel, x); + if (used) x += used + FOOTER_ITEM_GAP; + place(this.dateIcon_, this.dateText_, date, x); + } + /** * Places the pin marker between the collapse button and the title. * @@ -425,3 +584,25 @@ export class Note extends RenderedNoteBase { return data; } } + +/** + * Renders a stored timestamp as a short, local date. + * + * The metadata holds ISO 8601 strings so they survive a round trip verbatim; + * this is only for reading. An unparseable or absent value yields an empty + * string, which the footer treats as "nothing to show" rather than printing + * "Invalid Date" on the note. + * + * @param iso An ISO 8601 timestamp, or an empty string. + * @returns The date in the viewer's locale, or '' if there is not one. + */ +function formatDate(iso: string): string { + if (!iso) return ''; + const date = new Date(iso); + if (isNaN(date.getTime())) return ''; + return date.toLocaleDateString(undefined, { + day: 'numeric', + month: 'short', + year: 'numeric', + }); +} diff --git a/blockly-workspace-notes/src/model/note_mixin.ts b/blockly-workspace-notes/src/model/note_mixin.ts index 0f7c417..77762f3 100644 --- a/blockly-workspace-notes/src/model/note_mixin.ts +++ b/blockly-workspace-notes/src/model/note_mixin.ts @@ -149,6 +149,26 @@ const NoteMixin = (Base: TBase) => this.getNoteState().meta = {...this.getNoteState().meta, ...meta}; } + /** + * Records a body edit, then hands off to core. + * + * The note's own setters all touch the metadata through + * `changeNoteProperty_`, but the body is core's and goes nowhere near it - + * so without this, `updatedAt` would track the title, colour, pin and + * stacking order while ignoring the thing people actually spend their time + * changing, and a note edited all afternoon would still claim it was last + * touched when it was named. + * + * No event of our own: core already fires its own change event for the + * text, and the timestamp rides along in the note's state. + * + * @param text The new body text. + */ + setText(text: string): void { + if (text !== this.getText()) this.touchMeta(); + super.setText(text); + } + /** Records that the note changed just now. */ touchMeta(): void { this.getNoteState().meta.updatedAt = new Date().toISOString(); diff --git a/blockly-workspace-notes/src/ui/css.ts b/blockly-workspace-notes/src/ui/css.ts index 7deca92..ee496c2 100644 --- a/blockly-workspace-notes/src/ui/css.ts +++ b/blockly-workspace-notes/src/ui/css.ts @@ -26,6 +26,8 @@ import * as Blockly from 'blockly/core'; import { NOTE_CLASS, + FOOTER_CLASS, + FOOTER_TEXT_CLASS, PIN_CLASS, SELECTION_CLASS, PINNED_CLASS, @@ -37,6 +39,8 @@ import { BAR_ICON_MARGIN, BAR_ICON_SIZE, BODY_INSET, + FOOTER_FONT_SIZE, + FOOTER_HEIGHT, SCROLLBAR_WIDTH, TITLE_FONT_SIZE, TOPBAR_HEIGHT, @@ -168,6 +172,7 @@ Blockly.Css.register(` .${NOTE_CLASS} .blocklyMinimalBody { box-sizing: border-box; padding: ${BODY_INSET}px; + padding-bottom: ${BODY_INSET + FOOTER_HEIGHT}px; } /* @@ -383,6 +388,54 @@ Blockly.Css.register(` stroke: none; } +/* + * The footer: the author and the date, in the note's ink at a caption's + * weight. Faded rather than given a second colour, so it recedes without + * leaving the palette. + * + * Hidden on a collapsed note, which has no body for it to sit under, and on + * one carrying neither an author nor a date - renderFooter sets the attribute, + * because a group whose children are all display:none still has a box and + * would leave a blank strip reserved at the foot. + */ +.${NOTE_CLASS} .${FOOTER_CLASS} { + fill: none; + stroke: var(--noteInkColour); + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; + opacity: 0.6; + pointer-events: none; +} + +.${NOTE_CLASS} .${FOOTER_TEXT_CLASS} { + fill: var(--noteInkColour); + stroke: none; + font-size: ${FOOTER_FONT_SIZE}px; + dominant-baseline: middle; +} + +.${NOTE_CLASS}.blocklyCollapsed .${FOOTER_CLASS}, +.${NOTE_CLASS} .${FOOTER_CLASS}[data-empty='true'] { + display: none; +} + +/* + * The footer is in the root group, which core does not mirror, so unlike the + * title there is no mirror to undo here - setting the direction is the whole + * of it. + * + * That is also all it needs. text-anchor is left at its default of start, + * which anchors to the start of the inline base direction: the left edge under + * ltr and the right edge under rtl. Since renderFooter measures its offsets + * from the note's leading edge either way, the two agree without a second + * rule. Setting end here would anchor the left edge and run the text back + * across the note. + */ +.blocklyRTL .${NOTE_CLASS} .${FOOTER_TEXT_CLASS} { + direction: rtl; +} + .${NOTE_CLASS} .${SELECTION_CLASS} { fill: none; stroke: none; diff --git a/blockly-workspace-notes/src/ui/icons.ts b/blockly-workspace-notes/src/ui/icons.ts index 9eec73d..e6ff7d8 100644 --- a/blockly-workspace-notes/src/ui/icons.ts +++ b/blockly-workspace-notes/src/ui/icons.ts @@ -31,6 +31,21 @@ export const TRASH_GLYPH = [ 'M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3', ]; +/** Tabler's `user`, for the author in the footer. */ +export const USER_GLYPH = [ + 'M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0', + 'M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2', +]; + +/** Tabler's `calendar-event`, for the date in the footer. */ +export const CALENDAR_GLYPH = [ + 'M4 7a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12', + 'M16 3l0 4', + 'M8 3l0 4', + 'M4 11l16 0', + 'M8 15h2v2h-2l0 -2', +]; + /** * Tabler's `pinned`, for the marker on a pinned note. * diff --git a/blockly-workspace-notes/test/note.mocha.js b/blockly-workspace-notes/test/note.mocha.js index 72b7e7a..a788c51 100644 --- a/blockly-workspace-notes/test/note.mocha.js +++ b/blockly-workspace-notes/test/note.mocha.js @@ -95,6 +95,45 @@ suite('Note model', function () { }); }); + suite('metadata', function () { + test('a body edit is recorded as a change', function () { + const note = new NoteComment(this.workspace); + const before = note.getMeta().updatedAt; + note.restoreMeta({updatedAt: '2000-01-01T00:00:00.000Z'}); + + note.setText('written just now'); + + assert.notEqual( + note.getMeta().updatedAt, + '2000-01-01T00:00:00.000Z', + 'writing in a note should count as changing it', + ); + assert.isString(before); + }); + + test('setting the same text again changes nothing', function () { + const note = new NoteComment(this.workspace); + note.setText('same'); + note.restoreMeta({updatedAt: '2000-01-01T00:00:00.000Z'}); + + note.setText('same'); + + assert.equal(note.getMeta().updatedAt, '2000-01-01T00:00:00.000Z'); + }); + + test('restoring metadata does not stamp a new time', function () { + const note = new NoteComment(this.workspace); + note.restoreMeta({ + author: 'ada', + createdAt: '2020-05-05T00:00:00.000Z', + updatedAt: '2020-05-05T00:00:00.000Z', + }); + + assert.equal(note.getMeta().author, 'ada'); + assert.equal(note.getMeta().updatedAt, '2020-05-05T00:00:00.000Z'); + }); + }); + suite('pinning', function () { test('pinning locks the note in place', function () { const note = new NoteComment(this.workspace); From 3a6217b70939f48acef0b9025c16f9d4f9decd64 Mon Sep 17 00:00:00 2001 From: Preet Vadaliya Date: Wed, 9 Sep 2026 03:25:49 +0200 Subject: [PATCH 15/20] fix: give note text the workspace font and centre it properly --- .../docs/images/note-anatomy.svg | 6 ++--- .../docs/images/note-states.svg | 24 +++++++++---------- blockly-workspace-notes/src/model/note.ts | 2 +- blockly-workspace-notes/src/ui/css.ts | 22 +++++++++++++---- 4 files changed, 34 insertions(+), 20 deletions(-) diff --git a/blockly-workspace-notes/docs/images/note-anatomy.svg b/blockly-workspace-notes/docs/images/note-anatomy.svg index 76249d2..166d960 100644 --- a/blockly-workspace-notes/docs/images/note-anatomy.svg +++ b/blockly-workspace-notes/docs/images/note-anatomy.svg @@ -25,7 +25,7 @@
- Shopping list + Shopping list milk @@ -42,7 +42,7 @@
- ada + ada @@ -51,7 +51,7 @@ - 9 Sep 2026 + 9 Sep 2026 diff --git a/blockly-workspace-notes/docs/images/note-states.svg b/blockly-workspace-notes/docs/images/note-states.svg index 0cedfde..2afe4e0 100644 --- a/blockly-workspace-notes/docs/images/note-states.svg +++ b/blockly-workspace-notes/docs/images/note-states.svg @@ -15,17 +15,17 @@ - Shopping list + Shopping list milk bread - ada + ada - 9 Sep 2026 + 9 Sep 2026 Not named yet @@ -39,12 +39,12 @@ - Title + Title Say something... - 9 Sep 2026 + 9 Sep 2026 Collapsed @@ -58,7 +58,7 @@ - Release checklist + Release checklist Pinned @@ -75,16 +75,16 @@ - Do not move + Do not move Locked in place. - ada + ada - 9 Sep 2026 + 9 Sep 2026 Selected @@ -98,16 +98,16 @@ - Ideas + Ideas Try a smaller step. - ada + ada - 9 Sep 2026 + 9 Sep 2026 diff --git a/blockly-workspace-notes/src/model/note.ts b/blockly-workspace-notes/src/model/note.ts index e20ab1b..481e03a 100644 --- a/blockly-workspace-notes/src/model/note.ts +++ b/blockly-workspace-notes/src/model/note.ts @@ -189,7 +189,7 @@ export class Note extends RenderedNoteBase { const label = () => Blockly.utils.dom.createSvgElement( Blockly.utils.Svg.TEXT, - {'class': FOOTER_TEXT_CLASS}, + {'class': `${FOOTER_TEXT_CLASS} blocklyText`}, this.footer_, ); this.authorIcon_ = glyph(USER_GLYPH); diff --git a/blockly-workspace-notes/src/ui/css.ts b/blockly-workspace-notes/src/ui/css.ts index ee496c2..c3d2bd3 100644 --- a/blockly-workspace-notes/src/ui/css.ts +++ b/blockly-workspace-notes/src/ui/css.ts @@ -256,15 +256,21 @@ Blockly.Css.register(` } /* - * Title. A heading on the paper, so it takes the weight of one, and a click + * Title. A heading on the card, so it takes the weight of one, and a click * opens its editor the way a click on a field does. * + * central rather than middle: middle sits the text half an x-height above + * the baseline, which leaves it about a pixel high of the row it shares with + * the two glyphs. Both keywords are independent of the letters used - a title + * full of descenders lands where an all-caps one does - so this is simply the + * one that centres. + * * The type is set below rather than here: the renderer writes the font * SHORTHAND, which resets weight and size, so a rule at this specificity * would lose both. */ .${TITLE_CLASS} { - dominant-baseline: middle; + dominant-baseline: central; user-select: none; cursor: text; } @@ -408,11 +414,19 @@ Blockly.Css.register(` pointer-events: none; } -.${NOTE_CLASS} .${FOOTER_TEXT_CLASS} { +/* + * Carries blocklyText so it inherits the workspace's font rather than falling + * through to the SVG default, which is a serif. That means the renderer's own + * .blocklyText rule applies too, and it sets font - a shorthand, so it + * resets size and weight - at three classes. Hence four here, the same + * arithmetic the title rule above is doing and for the same reason. + */ +.${NOTE_CLASS}.blocklyComment .${FOOTER_TEXT_CLASS}.blocklyText { fill: var(--noteInkColour); stroke: none; font-size: ${FOOTER_FONT_SIZE}px; - dominant-baseline: middle; + font-weight: normal; + dominant-baseline: central; } .${NOTE_CLASS}.blocklyCollapsed .${FOOTER_CLASS}, From 6a9d001c246051b7a43a047edd4181c4bfba947c Mon Sep 17 00:00:00 2001 From: Preet Vadaliya Date: Wed, 9 Sep 2026 10:57:40 +0200 Subject: [PATCH 16/20] feat: add locked notes A locked note is read-only and cannot be deleted, resized or copied. It shows a padlock in the marker slot, taking it from the pin, and loses the delete button from its bar - which is also what enforces it, since core's own button does not check isDeletable before disposing of the comment. Locking drives editable and deletable; pinning keeps movable. Neither writes the other's flag, so the two compose. A host decides who may lock and unlock through canToggleLock, held per workspace rather than captured at registration so two workspaces cannot share one set of permissions. --- blockly-workspace-notes/docs/api.md | 29 ++- blockly-workspace-notes/docs/design.md | 67 ++++-- .../docs/getting-started.md | 17 ++ .../docs/images/note-states.svg | 27 ++- blockly-workspace-notes/docs/saving.md | 11 +- blockly-workspace-notes/docs/using-notes.md | 33 ++- blockly-workspace-notes/src/constants/dom.ts | 6 + .../src/constants/layout.ts | 56 +++-- .../src/constants/serialization.ts | 9 +- blockly-workspace-notes/src/index.ts | 1 + blockly-workspace-notes/src/model/note.ts | 192 +++++++++++++----- .../src/model/note_mixin.ts | 53 ++++- blockly-workspace-notes/src/plugin.ts | 12 ++ .../src/serialization/migrations.ts | 13 +- .../src/serialization/state.ts | 23 ++- .../src/serialization/xml/dom.ts | 2 + blockly-workspace-notes/src/types/note.ts | 6 +- blockly-workspace-notes/src/types/options.ts | 3 + .../src/types/serialization.ts | 10 +- .../src/ui/context_menu.ts | 45 +++- blockly-workspace-notes/src/ui/css.ts | 65 +++++- blockly-workspace-notes/src/ui/icons.ts | 12 ++ .../src/ui/lock_permission.ts | 68 +++++++ .../test/lock_permission.mocha.js | 103 ++++++++++ blockly-workspace-notes/test/note.mocha.js | 74 ++++++- .../test/serializer.mocha.js | 42 +++- blockly-workspace-notes/test/xml.mocha.js | 20 +- 27 files changed, 859 insertions(+), 140 deletions(-) create mode 100644 blockly-workspace-notes/src/ui/lock_permission.ts create mode 100644 blockly-workspace-notes/test/lock_permission.mocha.js diff --git a/blockly-workspace-notes/docs/api.md b/blockly-workspace-notes/docs/api.md index 92dce07..c3d1368 100644 --- a/blockly-workspace-notes/docs/api.md +++ b/blockly-workspace-notes/docs/api.md @@ -27,18 +27,31 @@ notes.getNotes().filter((note) => note.isPinned()); headless, which is what you get on a workspace with no renderer. Both carry the same note-specific methods on top of Blockly's own comment API. -| Method | What it does | -| ------------------------------ | --------------------------------------- | -| `getTitle()` / `setTitle(s)` | The heading | -| `getColour()` / `setColour(c)` | Any CSS colour; stored as hex | -| `isPinned()` / `setPinned(b)` | Locked in place and raised to the front | -| `getZIndex()` / `setZIndex(n)` | Stacking order; higher is nearer front | -| `getMeta()` | `{author, createdAt, updatedAt}` | -| `saveNoteState()` | A plain copy of all of the above | +| Method | What it does | +| ------------------------------ | -------------------------------------- | +| `getTitle()` / `setTitle(s)` | The heading | +| `getColour()` / `setColour(c)` | Any CSS colour; stored as hex | +| `isPinned()` / `setPinned(b)` | Held in place and raised to the front | +| `isLocked()` / `setLocked(b)` | Read-only, undeletable, uncopyable | +| `getZIndex()` / `setZIndex(n)` | Stacking order; higher is nearer front | +| `getMeta()` | `{author, createdAt, updatedAt}` | +| `saveNoteState()` | A plain copy of all of the above | Everything else — `getText`, `setText`, `moveTo`, `setSize`, `setCollapsed`, `dispose` — is Blockly's, and documented there. +Two things about `setLocked` are worth knowing before you build on it. + +**Locking owns `editable` and `deletable` outright.** Unlocking sets both back +to `true`, so if your app had independently made a note read-only and you then +lock and unlock it, that read-only state is gone. Drive one or the other, not +both. Pinning owns `movable` the same way, which is why the two compose +cleanly. + +**`canToggleLock` gates the menu, not the model.** `setLocked` always works +from code — undo, paste and loading a file all go through it. Locking states +intent and stops accidents; it is not a security boundary. + ## Helpers | Function | What it does | diff --git a/blockly-workspace-notes/docs/design.md b/blockly-workspace-notes/docs/design.md index 541e473..554687f 100644 --- a/blockly-workspace-notes/docs/design.md +++ b/blockly-workspace-notes/docs/design.md @@ -13,14 +13,15 @@ because people will trust it and then lose work. Blockly already has workspace comments — a plain box you can type into. They already handle a great deal: -| Already in Blockly | New here | -| -------------------------------------------- | ----------------------- | -| A box you can type into | A title | -| Drag to move, drag a corner to resize | A colour per note | -| Collapse, delete, copy and paste | Pinning — lock in place | -| Keyboard navigation and screen-reader labels | Stacking order | -| Undo and redo | Author and dates | -| | A versioned save format | +| Already in Blockly | New here | +| -------------------------------------------- | ------------------------ | +| A box you can type into | A title | +| Drag to move, drag a corner to resize | A colour per note | +| Collapse, delete, copy and paste | Pinning — hold in place | +| Keyboard navigation and screen-reader labels | Locking — make read-only | +| Undo and redo | Stacking order | +| | Author and dates | +| | A versioned save format | Everything in the left column keeps working exactly as people already expect. @@ -73,12 +74,52 @@ The cost is that a click on the title does two things depending on whether it moved. Blockly's own gesture code draws that line at the drag radius, and this follows it, so dragging a note by its title still works. -### Why "pinned" means locked, not fixed to the screen +### Why "pinned" means held to the canvas, not fixed to the screen -Pinned could mean locked to the canvas, or fixed to the screen like a -heads-up display. Locked won: a note that floats over your blocks wherever you -pan is more annoying than helpful, and the screen-fixed version fights the way -a workspace scrolls and zooms. +Pinned could mean held to the canvas, or fixed to the screen like a heads-up +display. Held won: a note that floats over your blocks wherever you pan is more +annoying than helpful, and the screen-fixed version fights the way a workspace +scrolls and zooms. + +### Why locking is not pinning + +They sound alike and they are not. Pinning is about _where a note is_ — it stops +being dragged and comes to the front. Locking is about _what a note says_ — it +stops being edited and cannot be deleted. + +Underneath, Blockly gives a comment three independent flags, and each of the two +features owns a disjoint set: locking drives `editable` and `deletable`, pinning +drives `movable`, and neither ever writes the other's. That is not tidiness for +its own sake. If both wrote `movable`, unpinning a locked note would quietly +hand its movement back, and which behaviour you got would depend on the order +you happened to do things in. Keeping them disjoint is what makes all four +combinations mean exactly what they look like. + +The cost accepted in exchange: unlocking sets both of its flags to `true` +outright, so locking owns them. A host wanting its own read-only notion should +not also drive those two. + +The title bar has one marker slot, and a lock takes it from a pin. A note that +cannot be edited is the more urgent of the two facts to convey, and the pin +comes back the moment it is unlocked. The alternative — showing both — costs the +title a fifth of its width on the notes most likely to have something to say. + +### Why the host decides who can unlock + +Locking exists because someone wants a note to survive being read by someone +else: a teacher's instructions on a student's workspace. That only works if the +student cannot simply unlock it, and the plugin has no idea who is looking. So +the host supplies a predicate and the plugin asks it. + +It is held per workspace rather than captured once, because every other +registration here is first-registration-wins — harmless for a palette, wrong +for a permission, since a host running two workspaces would otherwise let +whichever loaded first decide for both. + +And it gates the menu, not the model. `setLocked` has to keep working from code +or undo, paste and loading a file would all break. Locking states intent and +stops accidents; it is not a security boundary, and anything that depends on it +should be checked where the file is saved. ### Why every colour comes from one diff --git a/blockly-workspace-notes/docs/getting-started.md b/blockly-workspace-notes/docs/getting-started.md index dd9ffa6..f5c486a 100644 --- a/blockly-workspace-notes/docs/getting-started.md +++ b/blockly-workspace-notes/docs/getting-started.md @@ -43,6 +43,7 @@ new WorkspaceNotes(workspace, { | `palette` | 7 stationery colours | The swatches offered in the Colour menu | | `defaultSize` | `260 × 180` | The size a new note is created at | | `getAuthor` | `() => ''` | Names the note's author, shown along its foot | +| `canToggleLock` | `() => true` | Decides who may lock and unlock a note | | `contextMenu` | `true` | Adds the note items to the right-click menu | | `xmlSupport` | `true` | Keeps notes intact through the older XML format | | `skipSerializerRegistration` | `false` | Leaves saving and loading entirely to your app | @@ -61,6 +62,22 @@ new WorkspaceNotes(workspace, { }).init(); ``` +### Who may unlock a note + +A locked note is read-only and cannot be deleted. By default anyone can lock +and unlock one; pass `canToggleLock` when that is somebody's decision to make: + +```js +new WorkspaceNotes(workspace, { + canToggleLock: () => currentUser.isTeacher, +}).init(); +``` + +The predicate is given the note, so it can answer differently for different +ones. It gates the menu, not the model — `note.setLocked(false)` still works +from code, because undo, paste and loading a file all depend on it. If the +guarantee matters, enforce it where you save. + ## Turning it off `dispose()` puts Blockly back exactly as it found it — the menu items, the diff --git a/blockly-workspace-notes/docs/images/note-states.svg b/blockly-workspace-notes/docs/images/note-states.svg index 2afe4e0..111260c 100644 --- a/blockly-workspace-notes/docs/images/note-states.svg +++ b/blockly-workspace-notes/docs/images/note-states.svg @@ -1,6 +1,6 @@ The states a note can be in - Five notes: a named note, one not named yet, a collapsed one, a pinned one showing its marker, and a selected one. + Six notes: a named note, one not named yet, a collapsed one, a pinned one showing its marker, a selected one, and a locked one showing a padlock and no delete button. @@ -76,7 +76,7 @@ stroke="#4c171f" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> Do not move - Locked in place. + Held in place. @@ -111,4 +111,27 @@ + Locked + a padlock in the bar, and no bin + + + + + + Read the brief + Written by your teacher. + + ada + + 9 Sep 2026 + + diff --git a/blockly-workspace-notes/docs/saving.md b/blockly-workspace-notes/docs/saving.md index baf524e..7da00fc 100644 --- a/blockly-workspace-notes/docs/saving.md +++ b/blockly-workspace-notes/docs/saving.md @@ -27,6 +27,7 @@ Notes get their own key in the file, beside `blocks`: "title": "TODO", "colour": "#f9bbc5", "pinned": true, + "locked": true, "zIndex": 3, "meta": { "author": "ada", @@ -49,6 +50,7 @@ Everything that gets remembered: | The title | Only if it has one | | The colour | Only if it is not the default | | Pinned | Only if pinned | +| Locked | Only if locked; it implies neither editable nor deletable, so neither is written | | Stacking order | So overlapping notes come back in the same order | | Collapsed | Only if collapsed | | Author and timestamps | Author comes from `getAuthor`; dates are automatic, and `updatedAt` moves on any edit including the body | @@ -59,8 +61,11 @@ Everything that gets remembered: neither. Files stay small, and a change to one note shows up as a small diff rather than a wall of text. -**The payload carries a version.** If the format ever gains a field, files -saved today are quietly upgraded as they load. +**The payload carries a version.** It moves when a file written by an older +release cannot simply be read by a newer one — a field renamed, retyped or +moved — and such files are then upgraded quietly as they load. Adding an +optional field is not that: it is absent from older files, which is exactly +what its default means, so the version stays where it is. **Old files still open.** A workspace saved before this plugin existed, with plain Blockly comments in it, loads correctly — each comment becomes a note @@ -80,7 +85,7 @@ A note is written as a `` element with a few extra attributes: ```xml Refactor this loop ``` diff --git a/blockly-workspace-notes/docs/using-notes.md b/blockly-workspace-notes/docs/using-notes.md index 7efe62b..c5afc12 100644 --- a/blockly-workspace-notes/docs/using-notes.md +++ b/blockly-workspace-notes/docs/using-notes.md @@ -8,9 +8,11 @@ colour of its own, and room to write underneath.

The title bar carries two buttons — collapse on the left, delete on the right — -and a pin marker appears between them when a note is pinned. Along the foot, -a small line records who wrote the note and when it last changed. Everything -else a note can do is in the right-click menu. +and one marker slot between them. A pin appears there on a pinned note, a +padlock on a locked one, and the padlock wins if a note is both. A locked note +also loses the bin from its bar. Along the foot, a small line records who wrote +the note and when it last changed. Everything else a note can do is in the +right-click menu. ## Add one @@ -50,6 +52,20 @@ around the card. The pin is a marker, not a button — there is nothing to click, and it is only there while the note is pinned. Right-click → **Unpin note** to release it. +## Lock it + +Right-click → **Lock note** to make a note read-only. A locked note shows a +padlock in its title bar and loses the bin beside it. + +Locked, you cannot edit the text, rename it, recolour it, resize it, delete it +or copy it. You can still move it, collapse it, restack it, select it and read +it. Right-click → **Unlock note** to release it. + +Whether you may lock or unlock a note at all is up to the app — a teacher can +be allowed to lock an instruction note that a student cannot unlock. Where that +is the case, **Unlock note** is still in the menu but greyed out, so it is +clear the note is locked deliberately rather than broken. + ## Collapse it Press the chevron at the left of the title bar to fold a note down to that bar, @@ -81,20 +97,20 @@ on the workspace. - Drag the title row to move it. - Drag the bottom-right corner to resize. The title shortens with an ellipsis as you go, and the note stops at a size where it can still be read and - grabbed. + grabbed. A locked note has no resize handle. - **Duplicate note**, or copy and paste. The copy keeps the title, colour and - pinned state, and lands slightly offset. + pinned state, and lands slightly offset. A locked note cannot be copied. - **Delete note** from the menu, the bin at the right of the title bar, or - Delete while the note is selected. + Delete while the note is selected. None of the three works on a locked note. One press of undo brings a deleted note back complete — same text, same title, same colour, same pinned state. Every other change is undoable too: typing, -resizing, recolouring, renaming, pinning, reordering. +resizing, recolouring, renaming, pinning, locking, reordering. ## The states you will see

- A named note, one not named yet, a collapsed note, a pinned note showing its marker, and a selected note + A named note, one not named yet, a collapsed note, a pinned note showing its marker, a selected note, and a locked note showing a padlock and no bin

| State | How you can tell | @@ -103,6 +119,7 @@ resizing, recolouring, renaming, pinning, reordering. | **Not named yet** | A faded `Title`, and a faded prompt in the body | | **Collapsed** | Just the title bar, and the chevron points right | | **Pinned** | A pin in the title bar, and a heavier edge | +| **Locked** | A padlock in the title bar, and no bin beside it | | **Selected** | Blockly's own gold ring, following the card's corners | ## Keyboard and screen readers diff --git a/blockly-workspace-notes/src/constants/dom.ts b/blockly-workspace-notes/src/constants/dom.ts index 5d6b4d3..f318494 100644 --- a/blockly-workspace-notes/src/constants/dom.ts +++ b/blockly-workspace-notes/src/constants/dom.ts @@ -18,6 +18,12 @@ export const PINNED_CLASS = 'blocklyNotePinned'; /** CSS class of the group holding the marker drawn on a pinned note. */ export const PIN_CLASS = 'blocklyNotePin'; +/** CSS class added to a locked note. */ +export const LOCKED_CLASS = 'blocklyNoteLocked'; + +/** CSS class of the group holding the marker drawn on a locked note. */ +export const LOCK_CLASS = 'blocklyNoteLock'; + /** CSS class of the SVG text element that renders a note's title. */ export const TITLE_CLASS = 'blocklyNoteTitle'; diff --git a/blockly-workspace-notes/src/constants/layout.ts b/blockly-workspace-notes/src/constants/layout.ts index d562fc3..7ae2ad8 100644 --- a/blockly-workspace-notes/src/constants/layout.ts +++ b/blockly-workspace-notes/src/constants/layout.ts @@ -37,34 +37,45 @@ export const TITLE_LINE_HEIGHT = 16; export const TITLE_FONT_SIZE = TITLE_LINE_HEIGHT; /** - * The box the pin marker is drawn in. + * The box a title bar marker is drawn in. * - * One line box, so a pinned title row is exactly as tall as an unpinned one and - * the row never needs remeasuring. The glyph itself is authored on a 24-unit - * grid and scaled to fit, which also thins its 2-unit stroke to about 1.3px - - * about right for a mark that should read as punctuation beside the heading + * One line box, so a marked title row is exactly as tall as an unmarked one + * and the row never needs remeasuring. The glyphs are authored on a 24-unit + * grid and scaled to fit, which also thins their 2-unit stroke to about 1.3px + * - about right for a mark that should read as punctuation beside the heading * rather than as a control. */ -export const PIN_ICON_SIZE = TITLE_LINE_HEIGHT; +export const MARKER_ICON_SIZE = TITLE_LINE_HEIGHT; -/** The gap between the pin marker and the title it leads. */ -export const PIN_ICON_GAP = MEDIUM_PADDING; +/** The gap between a marker and the title it leads. */ +export const MARKER_ICON_GAP = MEDIUM_PADDING; + +/** Where a glyph's ink sits inside the grid it is authored on. */ +export interface GlyphInk { + x: number; + y: number; + width: number; + height: number; +} /** - * The grid the pin glyph is authored on, and where its ink actually sits - * within it. + * The grid every glyph in this plugin is authored on. * - * Tabler draws on 24 units but the pin only occupies x 7-17 and y 4-21, so - * roughly a third of the box is padding. Laying the marker out by that box - * would set it in from the note's margin by the padding and leave a gap to the - * title a third wider than asked for - which is exactly the sort of uneven - * spacing the rest of this layout is built to avoid. So the ink box is what - * gets positioned, and these are its numbers. + * Tabler draws on 24 units, but none of the glyphs fills that box - the pin + * takes x 7-17, the lock x 5-19 - so a fifth to a third of each one is + * padding. Laying a marker out by that box would set it in from the note's + * margin by the padding and leave a gap to the title wider than was asked for, + * which is exactly the sort of uneven spacing the rest of this layout is built + * to avoid. So the ink box is what gets positioned, and the two below are its + * numbers. */ -export const PIN_GLYPH_GRID = 24; +export const GLYPH_GRID = 24; /** The pin glyph's ink, in grid units. */ -export const PIN_GLYPH_INK = {x: 7, y: 4, width: 10, height: 17}; +export const PIN_GLYPH_INK: GlyphInk = {x: 7, y: 4, width: 10, height: 17}; + +/** The lock glyph's ink, in grid units. */ +export const LOCK_GLYPH_INK: GlyphInk = {x: 5, y: 3, width: 14, height: 18}; /** * The margin on every side of a note, and the single number the rest of the @@ -117,6 +128,15 @@ export const BAR_ICON_MARGIN = (TOPBAR_HEIGHT - BAR_ICON_SIZE) / 2; */ export const BAR_INSET = BAR_ICON_MARGIN + BAR_ICON_SIZE + BAR_ICON_GAP; +/** + * The inset at an end of the title row that carries no button. + * + * A locked note has no delete button, so the far end of its bar holds nothing + * but the note's own margin. Reserving a whole `BAR_INSET` there would leave + * the title stopping short of a gap with nothing in it. + */ +export const BAR_EMPTY_INSET = NOTE_MARGIN; + /** * How far the delete button moves to line up with the collapse button. * diff --git a/blockly-workspace-notes/src/constants/serialization.ts b/blockly-workspace-notes/src/constants/serialization.ts index 7d4a3a6..0fe6a6d 100644 --- a/blockly-workspace-notes/src/constants/serialization.ts +++ b/blockly-workspace-notes/src/constants/serialization.ts @@ -20,9 +20,12 @@ export const NOTE_SERIALIZER_NAME = 'workspaceNotes'; export const COMMENT_SERIALIZER_NAME = 'workspaceComments'; /** - * Current version of the `workspaceNotes` payload. Bump this whenever the - * shape changes, and add a matching entry to MIGRATIONS in - * `serialization/migrations.ts`. + * Current version of the `workspaceNotes` payload. + * + * Bump this when an older file cannot simply be read by the current code — a + * field renamed, retyped, moved or newly required — and add a matching entry + * to MIGRATIONS in `serialization/migrations.ts`. A new optional field is not + * such a change; see the note there. */ export const SCHEMA_VERSION = 1; diff --git a/blockly-workspace-notes/src/index.ts b/blockly-workspace-notes/src/index.ts index 91c708d..f542e26 100644 --- a/blockly-workspace-notes/src/index.ts +++ b/blockly-workspace-notes/src/index.ts @@ -56,4 +56,5 @@ export type { NoteSurface, } from './types/note'; export type {NotesPayload, SavedNote} from './types/serialization'; +export type {LockPredicate} from './ui/lock_permission'; export type {PaletteEntry, WorkspaceNotesOptions} from './types/options'; diff --git a/blockly-workspace-notes/src/model/note.ts b/blockly-workspace-notes/src/model/note.ts index 481e03a..b71dc8d 100644 --- a/blockly-workspace-notes/src/model/note.ts +++ b/blockly-workspace-notes/src/model/note.ts @@ -3,9 +3,9 @@ * state `model/note_mixin.ts` defines. * * Everything here is SVG the note adds to, or takes away from, the comment - * core already built — the card, the title, and the marker that shows a note - * is pinned — plus the plumbing that keeps that chrome in step with a comment - * core is resizing, collapsing and dragging underneath it. + * core already built — the card, the title, and the markers that show a note + * is pinned or locked — plus the plumbing that keeps that chrome in step with + * a comment core is resizing, collapsing and dragging underneath it. */ import * as Blockly from 'blockly/core'; @@ -14,6 +14,8 @@ import { NOTE_CLASS, FOOTER_CLASS, FOOTER_TEXT_CLASS, + LOCK_CLASS, + LOCKED_CLASS, PIN_CLASS, SELECTION_CLASS, PINNED_CLASS, @@ -22,6 +24,7 @@ import { UNTITLED_TITLE_TEXT, } from '../constants/dom'; import { + BAR_EMPTY_INSET, BAR_INSET, BODY_INSET, FOOTER_HANDLE_CLEARANCE, @@ -31,23 +34,27 @@ import { FOOTER_LABEL_GAP, MIN_FOOTER_AUTHOR_CHARS, MIN_SIZE, - PIN_GLYPH_GRID, + GLYPH_GRID, + LOCK_GLYPH_INK, + MARKER_ICON_GAP, + MARKER_ICON_SIZE, PIN_GLYPH_INK, - PIN_ICON_GAP, - PIN_ICON_SIZE, TOPBAR_HEIGHT, } from '../constants/layout'; +import type {GlyphInk} from '../constants/layout'; import type {NoteCopyData} from '../types/clipboard'; import {edgeFor, inkFor} from '../utils/colour'; import { CALENDAR_GLYPH, CHEVRON_GLYPH, + LOCK_GLYPH, PIN_GLYPH, TRASH_GLYPH, USER_GLYPH, glyphToDataUri, } from '../ui/icons'; import {editTitle} from '../ui/title_editor'; +import {msg} from '../utils/messages'; import {RenderedNoteBase} from './note_mixin'; import {restackNotes} from './stacking'; @@ -74,6 +81,9 @@ export class Note extends RenderedNoteBase { /** The marker shown while the note is pinned. */ private pin_?: SVGGElement; + /** The marker shown while the note is locked. */ + private lock_?: SVGGElement; + /** The rect that draws the selection ring. */ private selection_?: SVGRectElement; @@ -136,30 +146,54 @@ export class Note extends RenderedNoteBase { this.card_ = root.querySelector('.blocklyCommentHighlight'); /** - * The pin marker. Tabler's `pinned` glyph, authored on a 24-unit grid. + * The two title bar markers: Tabler's `pinned` and `lock`, both authored + * on a 24-unit grid, and both drawn into the same slot between the + * collapse button and the title. The stylesheet shows at most one, so the + * slot never carries two and the title's offset stays a single term. * - * It lives in the root group, *not* in core's top bar. Core mirrors that + * They live in the root group, *not* in core's top bar. Core mirrors that * bar wholesale in RTL and each of its children has to undo the mirror for - * itself; the root group is not mirrored, so the marker flips its own - * coordinates instead. + * itself; the root group is not mirrored, so a marker flips its own + * coordinates instead. Both are inserted after the bar in document order + * so they paint on top of it - the bar is opaque, and anything before it + * is simply covered. * - * `aria-hidden` because it is decoration: it repeats what `setMovable` - * already tells assistive technology, and a second announcement of the - * same fact is noise. The stylesheet hides it entirely unless the note is - * pinned, and keeps it out of the way of pointer events. + * They differ in what they tell assistive technology. The pin is + * decoration: pinning takes away one thing, the menu says so plainly, and + * anyone can undo it in a click. Locking takes away four at once, removes + * a visible button from the bar, and in the case this was built for cannot + * be undone by the person reading it - and core's only signal is the + * readonly attribute on the body, which is reached after focus is already + * inside it and says nothing about the title or the missing bin. So the + * lock is labelled and the pin is not. * @private */ - this.pin_ = Blockly.utils.dom.createSvgElement(Blockly.utils.Svg.G, { - 'class': PIN_CLASS, - 'aria-hidden': 'true', + let after: Element | null = topBar; + const marker = ( + cls: string, + paths: string[], + aria: Record, + ) => { + const g = Blockly.utils.dom.createSvgElement(Blockly.utils.Svg.G, { + 'class': cls, + ...aria, + }); + for (const d of paths) { + Blockly.utils.dom.createSvgElement(Blockly.utils.Svg.PATH, {'d': d}, g); + } + if (after) { + root.insertBefore(g, after.nextSibling); + after = g; + } else { + root.appendChild(g); + } + return g; + }; + this.pin_ = marker(PIN_CLASS, PIN_GLYPH, {'aria-hidden': 'true'}); + this.lock_ = marker(LOCK_CLASS, LOCK_GLYPH, { + 'role': 'img', + 'aria-label': msg('NOTE_LOCKED_LABEL', 'Locked'), }); - // After the top bar in document order, so it paints on top of it. The bar - // is opaque now, and anything inserted before it is simply covered. - if (topBar) { - root.insertBefore(this.pin_, topBar.nextSibling); - } else { - root.appendChild(this.pin_); - } /** * The footer: who wrote the note, and when it last changed. @@ -212,14 +246,6 @@ export class Note extends RenderedNoteBase { {'class': SELECTION_CLASS, 'aria-hidden': 'true'}, root, ); - for (const d of PIN_GLYPH) { - Blockly.utils.dom.createSvgElement( - Blockly.utils.Svg.PATH, - {'d': d}, - this.pin_, - ); - } - /** * The SVG text element showing the title above the writing area. * @private @@ -252,6 +278,8 @@ export class Note extends RenderedNoteBase { this.titleElement_.addEventListener('pointerup', (e) => { const press = this.titlePressPoint_; this.titlePressPoint_ = null; + // The editable check is also what keeps a locked note's title from + // opening, which is why the inline editor itself needs no lock handling. if (!press || !this.isEditable()) return; const travelled = Math.hypot(e.clientX - press.x, e.clientY - press.y); if (travelled > Blockly.config.dragRadius) return; @@ -377,12 +405,12 @@ export class Note extends RenderedNoteBase { } /** - * Draws the title and the pin marker, truncating the title to what is left - * of the width. + * Draws the title and its marker, truncating the title to what is left of + * the width. * * Called on every size change as well as every title change, since the - * truncation depends on both, and on pinning, which moves the title over to - * make room for the marker. + * truncation depends on both - and on pinning and locking, either of which + * moves the title over to make room for a marker. */ renderTitle() { if (!this.titleElement_ || this.isDeadOrDying()) return; @@ -405,23 +433,35 @@ export class Note extends RenderedNoteBase { // Core makes room for none of that - `calcMinSize` sums only its own two // buttons, and nothing at all knows about the marker - so these insets are // the only thing keeping the four from overlapping. - const pinAdvance = this.isPinned() - ? (PIN_GLYPH_INK.width * PIN_ICON_SIZE) / PIN_GLYPH_GRID + PIN_ICON_GAP - : 0; - const leading = BAR_INSET + pinAdvance; + // One marker slot, and a lock takes it from a pin: a note that cannot be + // edited is the more urgent of the two facts, and the pin comes back the + // moment it is unlocked. So the leading offset stays one term however many + // states the note is in at once. + const marker = this.isLocked() + ? LOCK_GLYPH_INK + : this.isPinned() + ? PIN_GLYPH_INK + : null; + const leading = BAR_INSET + (marker ? markerAdvance(marker) : 0); // Inside core's top bar group, which it mirrors in RTL - so x counts // inwards from the note's edge either way, and the sign follows. const dir = this.workspace.RTL ? -1 : 1; - this.positionPin_(dir); + this.positionMarker_(this.pin_, PIN_GLYPH_INK, dir); + this.positionMarker_(this.lock_, LOCK_GLYPH_INK, dir); this.titleElement_.setAttribute('x', `${dir * leading}`); this.titleElement_.setAttribute('y', `${TOPBAR_HEIGHT / 2}`); + // A locked note has no delete button, so the far end of its bar holds + // nothing but the margin. Reserving a button's worth there would leave the + // title stopping short of an empty gap. + const trailing = this.isLocked() ? BAR_EMPTY_INSET : BAR_INSET; + // Trim a character at a time; titles are short, so this settles fast. const maxWidth = Math.max( 0, - this.view.getSize().width - leading - BAR_INSET, + this.view.getSize().width - leading - trailing, ); let text = title; while ( @@ -457,7 +497,7 @@ export class Note extends RenderedNoteBase { const dir = this.workspace.RTL ? -1 : 1; const {width, height} = this.view.getSize(); const y = height - BODY_INSET - FOOTER_HEIGHT / 2; - const scale = FOOTER_ICON_SIZE / PIN_GLYPH_GRID; + const scale = FOOTER_ICON_SIZE / GLYPH_GRID; // `offset` is measured from the note's leading edge. The footer sits in // the root group, which core does not mirror - unlike the title bar - so @@ -530,30 +570,39 @@ export class Note extends RenderedNoteBase { } /** - * Places the pin marker between the collapse button and the title. + * Places a marker in the slot between the collapse button and the title. + * + * Both markers are positioned unconditionally, hidden or not: the stylesheet + * decides which one paints, and writing one attribute to a hidden group is + * cheaper than working out whether it was worth skipping. * - * The glyph is authored on a 24-unit grid, so it is scaled down to one line - * box. In RTL the whole top bar is mirrored by core, and the negative scale - * undoes that for the glyph itself - the same correction the title gets from - * the stylesheet. + * The glyphs are authored on a 24-unit grid, so they are scaled down to one + * line box. In RTL the whole top bar is mirrored by core, and the negative + * scale undoes that for the glyph itself - the same correction the title + * gets from the stylesheet. * + * @param el The marker group, if it has been built yet. + * @param ink Where the glyph's ink sits in its grid. * @param dir 1 in a left-to-right workspace, -1 in a right-to-left one. */ - private positionPin_(dir: number) { - if (!this.pin_) return; - const scale = PIN_ICON_SIZE / PIN_GLYPH_GRID; + private positionMarker_( + el: SVGGElement | undefined, + ink: GlyphInk, + dir: number, + ) { + if (!el) return; + const scale = MARKER_ICON_SIZE / GLYPH_GRID; // Offset by the glyph's own padding so it is the ink that starts where the // title row's contents do, rather than the empty box around it. - const x = dir * (BAR_INSET - PIN_GLYPH_INK.x * scale); - const y = - TOPBAR_HEIGHT / 2 - (PIN_GLYPH_INK.y + PIN_GLYPH_INK.height / 2) * scale; - this.pin_.setAttribute( + const x = dir * (BAR_INSET - ink.x * scale); + const y = TOPBAR_HEIGHT / 2 - (ink.y + ink.height / 2) * scale; + el.setAttribute( 'transform', `translate(${x}, ${y}) scale(${dir * scale}, ${scale})`, ); } - /** Locks or unlocks the note, and marks it visually. */ + /** Holds or releases the note, and marks it visually. */ applyPinned() { super.applyPinned(); const pinned = this.isPinned(); @@ -567,6 +616,20 @@ export class Note extends RenderedNoteBase { if (pinned) this.applyZIndex(); } + /** Marks the note read-only, and gives the lock the marker slot. */ + applyLocked() { + super.applyLocked(); + Blockly.utils.dom[this.isLocked() ? 'addClass' : 'removeClass']( + this.getSvgRoot(), + LOCKED_CLASS, + ); + // Mandatory, not defensive. Locking changes which marker shows and takes + // the delete button out of the bar, but `setEditable` fires no event and + // touches no size attribute, so the size observer never wakes and nothing + // else would lay the row out again. + this.renderTitle(); + } + /** Restacks every note on the workspace to match their z-indices. */ applyZIndex() { restackNotes(this.workspace); @@ -578,6 +641,9 @@ export class Note extends RenderedNoteBase { * @returns The copy data, or null if the note is not copyable. */ toCopyData(): NoteCopyData | null { + // Core's own version does not check this, so without it a locked note + // could still be copied by anything reaching past the context menu. + if (!this.isCopyable()) return null; const data = super.toCopyData() as NoteCopyData | null; if (!data) return null; data.noteState = this.saveNoteState(); @@ -585,6 +651,20 @@ export class Note extends RenderedNoteBase { } } +/** + * How much of the title row a marker takes, its gap included. + * + * Measured across the ink rather than the glyph's box, matching where + * `positionMarker_` puts it - the two have to agree or the title either + * collides with the marker or floats away from it. + * + * @param ink Where the glyph's ink sits in its grid. + * @returns The width to give the marker, in workspace units. + */ +function markerAdvance(ink: GlyphInk): number { + return (ink.width * MARKER_ICON_SIZE) / GLYPH_GRID + MARKER_ICON_GAP; +} + /** * Renders a stored timestamp as a short, local date. * diff --git a/blockly-workspace-notes/src/model/note_mixin.ts b/blockly-workspace-notes/src/model/note_mixin.ts index 77762f3..c603753 100644 --- a/blockly-workspace-notes/src/model/note_mixin.ts +++ b/blockly-workspace-notes/src/model/note_mixin.ts @@ -73,6 +73,7 @@ const NoteMixin = (Base: TBase) => title: '', colour: DEFAULT_COLOUR, pinned: false, + locked: false, zIndex: 0, meta: {author: '', createdAt: now, updatedAt: now}, }; @@ -113,7 +114,7 @@ const NoteMixin = (Base: TBase) => } /** - * Pins or unpins the note. A pinned note is locked in place and kept in + * Pins or unpins the note. A pinned note is held in place and kept in * front of its neighbours. * @param pinned Whether the note should be pinned. */ @@ -121,6 +122,20 @@ const NoteMixin = (Base: TBase) => this.changeNoteProperty_('pinned', !!pinned); } + /** @returns Whether the note is locked. */ + isLocked(): boolean { + return this.getNoteState().locked; + } + + /** + * Locks or unlocks the note. A locked note is read-only and cannot be + * deleted, but can still be moved, collapsed and restacked. + * @param locked Whether the note should be locked. + */ + setLocked(locked: boolean): void { + this.changeNoteProperty_('locked', !!locked); + } + /** @returns The note's stacking order; higher is nearer front. */ getZIndex(): number { return this.getNoteState().zIndex; @@ -184,6 +199,7 @@ const NoteMixin = (Base: TBase) => title: state.title, colour: state.colour, pinned: state.pinned, + locked: state.locked, zIndex: state.zIndex, meta: {...state.meta}, }; @@ -217,6 +233,10 @@ const NoteMixin = (Base: TBase) => state.pinned = value as boolean; this.applyPinned(); break; + case 'locked': + state.locked = value as boolean; + this.applyLocked(); + break; case 'zIndex': state.zIndex = value as number; this.applyZIndex(); @@ -232,10 +252,20 @@ const NoteMixin = (Base: TBase) => state.title = whole.title; state.colour = whole.colour; state.pinned = whole.pinned; + // Coerced, unlike its neighbours: '*' also replays a NoteChange + // serialized before locking existed, where the key is simply absent. + // Left undefined it would survive into `saveNoteState`, and the + // JSON.stringify comparison in `changeNoteProperty_` would then treat + // a real lock as no change at all. + state.locked = !!whole.locked; state.zIndex = whole.zIndex; state.meta = {...whole.meta}; this.renderTitle(); this.renderColour(); + // Before `applyPinned`, which ends by laying the title row out again: + // that pass should see both markers' final state, not one of them + // mid-flight. + this.applyLocked(); this.applyPinned(); this.applyZIndex(); break; @@ -282,11 +312,30 @@ const NoteMixin = (Base: TBase) => /** Reflects the colour in the DOM. Overridden by the rendered subclass. */ renderColour(): void {} - /** Applies the pinned flag. Locking works headlessly too. */ + /** Applies the pinned flag. Holding a note in place works headlessly. */ applyPinned(): void { this.setMovable(!this.getNoteState().pinned); } + /** + * Applies the locked flag, which is what actually makes a note read-only. + * + * Locking owns `editable` and `deletable`; pinning owns `movable`. Neither + * ever writes the other's flag, and that is the whole reason the two + * compose: if both drove `movable`, unpinning a locked note would quietly + * hand its movement back. + * + * Unlocking sets both flags to true unconditionally, so a host that had + * independently called `setEditable(false)` loses that when a note it + * locked is unlocked. Locking owns those two flags outright; a host that + * needs its own read-only state should not also drive them. + */ + applyLocked(): void { + const locked = this.getNoteState().locked; + this.setEditable(!locked); + this.setDeletable(!locked); + } + /** Applies the stacking order. Overridden by the rendered subclass. */ applyZIndex(): void {} diff --git a/blockly-workspace-notes/src/plugin.ts b/blockly-workspace-notes/src/plugin.ts index 79be6b9..1dec9d2 100644 --- a/blockly-workspace-notes/src/plugin.ts +++ b/blockly-workspace-notes/src/plugin.ts @@ -34,6 +34,7 @@ import { unregisterNoteSerializers, } from './serialization/registry'; import {createNote as makeNote} from './serialization/state'; +import {clearLockPermission, setLockPermission} from './ui/lock_permission'; import { registerXmlSupport, unregisterXmlSupport, @@ -87,6 +88,7 @@ export class WorkspaceNotes { emitLegacyComments: false, xmlSupport: true, getAuthor: () => '', + canToggleLock: () => true, ...options, }; } @@ -109,6 +111,11 @@ export class WorkspaceNotes { registerNoteChangeEvent(); + // Outside the contextMenu guard below: who may unlock a note is a fact + // about the workspace, not about whether this plugin drew the menu that + // asks. A host driving its own menu still needs to be able to ask. + setLockPermission(this.workspace, this.options.canToggleLock); + if (!this.options.skipSerializerRegistration) { registerNoteSerializers({ emitLegacyComments: this.options.emitLegacyComments, @@ -154,6 +161,8 @@ export class WorkspaceNotes { unregisterXmlSupport(); } + clearLockPermission(this.workspace); + if (this.options.contextMenu) { unregisterNoteContextMenu(); } @@ -175,6 +184,9 @@ export class WorkspaceNotes { note.moveTo(new Blockly.utils.Coordinate(state.x ?? 0, state.y ?? 0)); } note.setZIndex(nextZIndex(this.workspace)); + // After the z-index, which pinning would otherwise raise a second time. + if (state.pinned) note.setPinned(true); + if (state.locked) note.setLocked(true); note.restoreMeta({author: this.options.getAuthor()}); return note; }); diff --git a/blockly-workspace-notes/src/serialization/migrations.ts b/blockly-workspace-notes/src/serialization/migrations.ts index 52345ea..f4523c0 100644 --- a/blockly-workspace-notes/src/serialization/migrations.ts +++ b/blockly-workspace-notes/src/serialization/migrations.ts @@ -3,9 +3,16 @@ * version of this plugin. * * Every save file ever written has to keep loading, so this is the one place - * that knows what earlier versions looked like. Adding a field to `SavedNote` - * needs a `SCHEMA_VERSION` bump in `constants/serialization.ts` and an entry - * in `MIGRATIONS` below, keyed by the version it upgrades *from*. + * that knows what earlier versions looked like. A change that an older file + * cannot be read through — a renamed or retyped field, a moved one, or a new + * required one — needs a `SCHEMA_VERSION` bump in `constants/serialization.ts` + * and an entry in `MIGRATIONS` below, keyed by the version it upgrades *from*. + * + * A new *optional* field needs neither. Its absence is already its default, so + * the migration would be the identity function — and bumping is not free: + * `migrate` warns whenever a file's version runs ahead of the plugin reading + * it, so every file written by the new build would make an older deployed + * build complain about a field it was never going to honour anyway. */ import {SCHEMA_VERSION} from '../constants/serialization'; diff --git a/blockly-workspace-notes/src/serialization/state.ts b/blockly-workspace-notes/src/serialization/state.ts index bb51710..67e3bd9 100644 --- a/blockly-workspace-notes/src/serialization/state.ts +++ b/blockly-workspace-notes/src/serialization/state.ts @@ -73,13 +73,18 @@ export function saveNote( if (note.getText()) state.text = note.getText(); if (note.isCollapsed()) state.collapsed = true; - // `isOwn*` rather than `is*`: a read-only *workspace* must not poison the - // per-note flags we persist. - if (!note.isOwnEditable()) state.editable = false; - if (!note.isOwnDeletable()) state.deletable = false; - + // Read here rather than below with the rest of the note's own fields, + // because the three core flags underneath them are what these two imply. + // A plain comment made outside the plugin has neither accessor. + const locked = typeof note.isLocked === 'function' && note.isLocked(); const pinned = typeof note.isPinned === 'function' && note.isPinned(); - // A pinned note is immovable by definition, so `movable` would be noise. + + // `isOwn*` rather than `is*`: a read-only *workspace* must not poison the + // per-note flags we persist. A locked note is read-only and undeletable by + // definition, and a pinned one immovable, so writing those out as well would + // be noise - and would make a file say twice what it means once. + if (!note.isOwnEditable() && !locked) state.editable = false; + if (!note.isOwnDeletable() && !locked) state.deletable = false; if (!note.isOwnMovable() && !pinned) state.movable = false; // Note-specific fields. A plain comment created outside the plugin has @@ -93,6 +98,7 @@ export function saveNote( state.colour = note.getColour(); } if (pinned) state.pinned = true; + if (locked) state.locked = true; if (note.getZIndex()) state.zIndex = note.getZIndex(); const meta = note.getMeta(); @@ -165,6 +171,11 @@ export function appendNote( if (state.zIndex !== undefined) note.setZIndex(state.zIndex); // Applied after `movable`, which it overrides. if (state.pinned) note.setPinned(true); + // Likewise for `editable` and `deletable`. Truthy-gated rather than + // tested against undefined, unlike the fields above: `setLocked(false)` + // would restore both flags and undo an explicit `editable: false` + // applied a few lines up, and false is the default in any case. + if (state.locked) note.setLocked(true); // Last, and deliberately not through a setter: restoring metadata must // not stamp a fresh `updatedAt` over the one we just loaded. if (state.meta) note.restoreMeta(state.meta); diff --git a/blockly-workspace-notes/src/serialization/xml/dom.ts b/blockly-workspace-notes/src/serialization/xml/dom.ts index 2de509e..e8d89dd 100644 --- a/blockly-workspace-notes/src/serialization/xml/dom.ts +++ b/blockly-workspace-notes/src/serialization/xml/dom.ts @@ -33,6 +33,7 @@ export function decorateElement(elem: Element, state: SavedNote): void { if (state.title) elem.setAttribute('title', state.title); if (state.colour) elem.setAttribute('colour', state.colour); if (state.pinned) elem.setAttribute('pinned', 'true'); + if (state.locked) elem.setAttribute('locked', 'true'); // `z` rather than `zIndex`, grouping it with core's terse x/y/w/h geometry. if (state.zIndex) elem.setAttribute('z', `${state.zIndex}`); if (state.meta?.author) elem.setAttribute('author', state.meta.author); @@ -83,6 +84,7 @@ export function domToNoteState(elem: Element): SavedNote { if (colour) state.colour = colour; if (elem.getAttribute('pinned') === 'true') state.pinned = true; + if (elem.getAttribute('locked') === 'true') state.locked = true; const zIndex = parseInt(elem.getAttribute('z') ?? '', 10); if (!isNaN(zIndex)) state.zIndex = zIndex; diff --git a/blockly-workspace-notes/src/types/note.ts b/blockly-workspace-notes/src/types/note.ts index feed9d0..7674159 100644 --- a/blockly-workspace-notes/src/types/note.ts +++ b/blockly-workspace-notes/src/types/note.ts @@ -27,6 +27,7 @@ export interface NoteState { title: string; colour: string; pinned: boolean; + locked: boolean; zIndex: number; meta: NoteMeta; } @@ -38,7 +39,7 @@ export interface NoteState { * restore a note in one step. */ export type NoteProperty = - 'title' | 'colour' | 'pinned' | 'zIndex' | 'meta' | '*'; + 'title' | 'colour' | 'pinned' | 'locked' | 'zIndex' | 'meta' | '*'; /** * The value that goes with each `NoteProperty`. @@ -66,6 +67,8 @@ export interface NoteSurface { setColour(colour: string): void; isPinned(): boolean; setPinned(pinned: boolean): void; + isLocked(): boolean; + setLocked(locked: boolean): void; getZIndex(): number; setZIndex(zIndex: number): void; getMeta(): NoteMeta; @@ -76,5 +79,6 @@ export interface NoteSurface { renderTitle(): void; renderColour(): void; applyPinned(): void; + applyLocked(): void; applyZIndex(): void; } diff --git a/blockly-workspace-notes/src/types/options.ts b/blockly-workspace-notes/src/types/options.ts index faea2d4..ca88302 100644 --- a/blockly-workspace-notes/src/types/options.ts +++ b/blockly-workspace-notes/src/types/options.ts @@ -3,6 +3,8 @@ * and the palette entries it may supply. */ +import type {LockPredicate} from '../ui/lock_permission'; + /** * One entry in a note palette. * @@ -22,6 +24,7 @@ export interface WorkspaceNotesOptions { palette?: PaletteEntry[]; defaultSize?: {width: number; height: number}; getAuthor?: () => string; + canToggleLock?: LockPredicate; contextMenu?: boolean; skipSerializerRegistration?: boolean; emitLegacyComments?: boolean; diff --git a/blockly-workspace-notes/src/types/serialization.ts b/blockly-workspace-notes/src/types/serialization.ts index a9d84bd..4439aa7 100644 --- a/blockly-workspace-notes/src/types/serialization.ts +++ b/blockly-workspace-notes/src/types/serialization.ts @@ -2,9 +2,12 @@ * @fileoverview The on-disk shapes: one note, and the plugin's slice of a save * file. * - * These are a persisted format. A change here is a change to files already - * written by earlier versions, so it needs a `SCHEMA_VERSION` bump and a - * matching entry in `serialization/migrations.ts`. + * These are a persisted format, so a change here is a change to files already + * written by earlier versions. Adding an optional field whose default is its + * own absence is not one: older files simply lack it and read as the default. + * Changing what an existing field means, its type or where it lives — or + * making one required — needs a `SCHEMA_VERSION` bump and a matching entry in + * `serialization/migrations.ts`. */ import type {NoteMeta} from './note'; @@ -36,6 +39,7 @@ export interface SavedNote { title?: string; colour?: string; pinned?: boolean; + locked?: boolean; zIndex?: number; meta?: Partial; diff --git a/blockly-workspace-notes/src/ui/context_menu.ts b/blockly-workspace-notes/src/ui/context_menu.ts index f8e41ed..8eb02bd 100644 --- a/blockly-workspace-notes/src/ui/context_menu.ts +++ b/blockly-workspace-notes/src/ui/context_menu.ts @@ -19,6 +19,7 @@ import {DEFAULT_PALETTE} from '../constants/colours'; import {Note} from '../model/note'; import {nextZIndex, previousZIndex} from '../model/stacking'; import {msg} from '../utils/messages'; +import {canToggleLock} from './lock_permission'; import {asOneUndoStep} from '../utils/undo'; import {createSwatchRow} from './colour_swatches'; @@ -42,6 +43,7 @@ let registrationCount = 0; const NOTE_ITEM_IDS = [ 'noteColour', 'notePin', + 'noteLock', 'noteCollapse', 'noteBringToFront', 'noteSendToBack', @@ -87,8 +89,13 @@ export function registerNoteContextMenu({palette = DEFAULT_PALETTE} = {}) { noteFromScope(scope) ? msg('DUPLICATE_NOTE', 'Duplicate note') : msg('DUPLICATE_COMMENT', 'Duplicate Comment'), + // isCopyable, not isMovable: core defines the first as movable *and* + // deletable, so a locked note is already not copyable and this is what + // takes the item away. It also closes a smaller gap - on a note that was + // undeletable for any other reason the item used to show and then quietly + // do nothing, because the paste had no data to work from. preconditionFn: (scope) => - scope.comment?.isMovable() ? 'enabled' : 'hidden', + scope.comment?.isCopyable() ? 'enabled' : 'hidden', callback: (scope) => { const comment = scope.comment; const data = comment?.toCopyData(); @@ -167,7 +174,14 @@ export function registerNoteContextMenu({palette = DEFAULT_PALETTE} = {}) { noteFromScope(scope)?.isPinned() ? msg('UNPIN_NOTE', 'Unpin note') : msg('PIN_NOTE', 'Pin note'), - preconditionFn: (scope) => (noteFromScope(scope) ? 'enabled' : 'hidden'), + // A locked note nobody here may unlock must not be unpinnable either, or + // a note held in place deliberately can still be released and dragged off. + // This gates the item, not the flag: locking never writes `movable`. + preconditionFn: (scope) => { + const note = noteFromScope(scope); + if (!note) return 'hidden'; + return note.isLocked() && !canToggleLock(note) ? 'disabled' : 'enabled'; + }, callback: (scope) => { const note = noteFromScope(scope); if (!note) return; @@ -179,6 +193,33 @@ export function registerNoteContextMenu({palette = DEFAULT_PALETTE} = {}) { }, }); + registry.register({ + id: 'noteLock', + scopeType: ScopeType.COMMENT, + weight: 5, + displayText: (scope) => + noteFromScope(scope)?.isLocked() + ? msg('UNLOCK_NOTE', 'Unlock note') + : msg('LOCK_NOTE', 'Lock note'), + // The two refusals are not symmetric, and collapsing them loses the half + // that matters. A greyed "Unlock note" answers the question someone + // actually has in front of a note they cannot edit - it is locked, + // unlocking exists, it is not theirs to do - where hiding the item + // explains nothing and reads as a bug. A greyed "Lock note" on every + // unlocked note explains nothing either, and says it constantly. + preconditionFn: (scope) => { + const note = noteFromScope(scope); + if (!note) return 'hidden'; + if (canToggleLock(note)) return 'enabled'; + return note.isLocked() ? 'disabled' : 'hidden'; + }, + callback: (scope) => { + const note = noteFromScope(scope); + if (!note) return; + asOneUndoStep(() => note.setLocked(!note.isLocked())); + }, + }); + // Core registers no collapse item for comments - registerCollapseExpandBlock // is ScopeType.BLOCK only - so with the foldout arrow gone from the bar this // is the only way to collapse a note. diff --git a/blockly-workspace-notes/src/ui/css.ts b/blockly-workspace-notes/src/ui/css.ts index c3d2bd3..32a455f 100644 --- a/blockly-workspace-notes/src/ui/css.ts +++ b/blockly-workspace-notes/src/ui/css.ts @@ -28,6 +28,8 @@ import { NOTE_CLASS, FOOTER_CLASS, FOOTER_TEXT_CLASS, + LOCK_CLASS, + LOCKED_CLASS, PIN_CLASS, SELECTION_CLASS, PINNED_CLASS, @@ -59,7 +61,7 @@ Blockly.Css.register(` } /* - * A pinned note is locked in place, and says so twice: a marker at the head of + * A pinned note is held in place, and says so twice: a marker at the head of * the title row, and a heavier edge. * * Two quiet signals rather than one loud one. The edge is what carries at a @@ -71,18 +73,22 @@ Blockly.Css.register(` } /* - * The marker itself: Tabler's pin, stroked rather than filled so it sits at the - * weight of the heading it leads rather than as a solid blot on the paper. + * The markers themselves: Tabler's pin and lock, stroked rather than filled so + * they sit at the weight of the heading they lead rather than as solid blots + * on the paper. * * display:none rather than visibility, matching how core hides its own bar * buttons - CommentBarButton.canBeFocused() defers to checkVisibility(), so a * display:none element is skipped by keyboard navigation rather than trapped - * on. It is decoration either way, and aria-hidden in the markup. + * on. It also keeps whichever marker is hidden out of the accessibility tree + * altogether, which matters here because the lock, unlike the pin, is + * labelled. * * pointer-events:none because the title row is the note's drag handle. A note - * has to stay draggable by the part of the row the marker occupies. + * has to stay draggable by the part of the row a marker occupies. */ -.${NOTE_CLASS} .${PIN_CLASS} { +.${NOTE_CLASS} .${PIN_CLASS}, +.${NOTE_CLASS} .${LOCK_CLASS} { display: none; fill: none; stroke: var(--noteInkColour); @@ -92,7 +98,25 @@ Blockly.Css.register(` pointer-events: none; } -.${NOTE_CLASS}.${PINNED_CLASS} .${PIN_CLASS} { +/* + * One slot, and a lock takes it from a pin: a note that cannot be edited is + * the more urgent of the two facts, and the pin comes back the moment it is + * unlocked. + * + * The :not() carries its argument's specificity, so at four classes this is + * the single rule deciding when a pin paints - rather than a show rule and a + * second one undoing it, which is the arrangement that goes wrong later. + */ +.${NOTE_CLASS}.${PINNED_CLASS}:not(.${LOCKED_CLASS}) .${PIN_CLASS} { + display: block; +} + +/* + * A locked note gets no third border weight to go with this. Pinning already + * doubles the edge, and a note that is both would then be indistinguishable + * from one that is only pinned. The glyph and the missing bin say it instead. + */ +.${NOTE_CLASS}.${LOCKED_CLASS} .${LOCK_CLASS} { display: block; } @@ -147,6 +171,33 @@ Blockly.Css.register(` transform: translateX(${BAR_DELETE_NUDGE}px); } +/* + * A locked note cannot be deleted, and this is what enforces it in the bar. + * + * Core's delete button does not check isDeletable() before acting: the menu + * item and the keyboard shortcut both do, but the button calls dispose on the + * comment view outright. So hiding it is not decoration. + * + * display, not visibility, and for a sharper reason than the markers above. + * CommentBarButton.isVisible() is checkVisibility(), which by default ignores + * the visibility property - so a button hidden that way would still report + * itself visible, stay in the keyboard tab order, and still delete the note. + * + * Three classes, because the rule showing both bar buttons is two. + */ +.${NOTE_CLASS}.${LOCKED_CLASS} .blocklyDeleteIcon { + display: none; +} + +/* + * The resize handle goes with it. Core already refuses to act on the handle - + * onResizePointerDown is gated on isEditable - so all this stops is a locked + * note offering a grab handle that does nothing when you pull it. + */ +.${NOTE_CLASS}.${LOCKED_CLASS} .blocklyResizeHandle { + display: none; +} + /* * Core styles no focus ring for these, so a keyboard user would otherwise get * the browser's default outline on a bare . The note's own edge colour diff --git a/blockly-workspace-notes/src/ui/icons.ts b/blockly-workspace-notes/src/ui/icons.ts index e6ff7d8..218a105 100644 --- a/blockly-workspace-notes/src/ui/icons.ts +++ b/blockly-workspace-notes/src/ui/icons.ts @@ -58,6 +58,18 @@ export const PIN_GLYPH = [ 'M8 4l8 0', ]; +/** + * Tabler's `lock`, for the marker on a locked note. + * + * Drawn inline for the same reason as the pin: it is the plugin's own element + * rather than one of core's, so CSS can stroke it in the note's ink directly. + */ +export const LOCK_GLYPH = [ + 'M5 13a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v6a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-6', + 'M11 16a1 1 0 1 0 2 0a1 1 0 0 0 -2 0', + 'M8 11v-4a4 4 0 1 1 8 0v4', +]; + /** * Renders a glyph as a `data:` URI, stroked in the given colour. * diff --git a/blockly-workspace-notes/src/ui/lock_permission.ts b/blockly-workspace-notes/src/ui/lock_permission.ts new file mode 100644 index 0000000..64946e9 --- /dev/null +++ b/blockly-workspace-notes/src/ui/lock_permission.ts @@ -0,0 +1,68 @@ +/** + * @fileoverview Who is allowed to lock and unlock a note. + * + * Locking is the one thing a note does that is a matter of permission rather + * than preference, and the plugin has no idea what a host's permissions are. + * So the host supplies a predicate and this module holds it. + * + * It is held per workspace, deliberately. Every other registration the plugin + * makes is reference-counted and first-registration-wins, because the Blockly + * registries it writes into are global singletons and sharing a palette + * between two workspaces is harmless. Sharing a *permission* is not: a host + * running an authoring workspace beside a read-only one would hand whichever + * plugin instance happened to initialize first the final say over both. The + * WeakMap here is what keeps the two apart, and it lets a disposed workspace + * be collected even if `dispose` is never called. + * + * This gates the context menu, and nothing else. `setLocked` still works from + * code — it has to, or undo, paste and loading a file would all break — so + * this is an affordance, not a security boundary. A host that needs the + * guarantee enforces it where it saves. + */ + +import type * as Blockly from 'blockly/core'; + +import type {Note} from '../model/note'; +import type {NoteComment} from '../model/note_comment'; + +/** Decides whether a note's lock may be changed by hand. */ +export type LockPredicate = (note: Note | NoteComment) => boolean; + +/** The predicate each workspace was given, if it was given one. */ +const predicates = new WeakMap(); + +/** + * Records who may lock and unlock notes on a workspace. + * + * @param workspace The workspace the rule applies to. + * @param predicate The host's rule. + */ +export function setLockPermission( + workspace: Blockly.Workspace, + predicate: LockPredicate, +): void { + predicates.set(workspace, predicate); +} + +/** + * Forgets a workspace's rule, restoring the permissive default. + * + * @param workspace The workspace to forget. + */ +export function clearLockPermission(workspace: Blockly.Workspace): void { + predicates.delete(workspace); +} + +/** + * Asks whether this note's lock may be changed by hand. + * + * Permissive when no rule was set, so a host that never mentions locking gets + * a lock that simply works. + * + * @param note The note in question. + * @returns Whether the menu should offer to lock or unlock it. + */ +export function canToggleLock(note: Note | NoteComment): boolean { + const predicate = predicates.get(note.workspace); + return predicate ? !!predicate(note) : true; +} diff --git a/blockly-workspace-notes/test/lock_permission.mocha.js b/blockly-workspace-notes/test/lock_permission.mocha.js new file mode 100644 index 0000000..476832b --- /dev/null +++ b/blockly-workspace-notes/test/lock_permission.mocha.js @@ -0,0 +1,103 @@ +/** + * @fileoverview Tests for who may lock and unlock a note. + * + * The context menu's own preconditions cannot be reached headlessly — they + * narrow on `instanceof Note`, and a Node test can only build a `NoteComment` + * — so the rule they consult is tested here directly instead. + */ + +import * as Blockly from 'blockly/core'; +import {assert} from 'chai'; + +import {NoteComment, WorkspaceNotes} from '../src/index'; +import { + canToggleLock, + clearLockPermission, + setLockPermission, +} from '../src/ui/lock_permission'; + +suite('Lock permission', function () { + setup(function () { + this.workspace = new Blockly.Workspace(); + }); + + teardown(function () { + clearLockPermission(this.workspace); + this.workspace.dispose(); + }); + + test('a workspace with no rule lets anyone lock', function () { + const note = new NoteComment(this.workspace); + assert.isTrue(canToggleLock(note)); + }); + + test('a rule that refuses is honoured', function () { + const note = new NoteComment(this.workspace); + setLockPermission(this.workspace, () => false); + assert.isFalse(canToggleLock(note)); + }); + + test('the rule is asked about the note in front of it', function () { + const plain = new NoteComment(this.workspace); + const titled = new NoteComment(this.workspace); + titled.setTitle('Brief'); + setLockPermission(this.workspace, (note) => !note.getTitle()); + assert.isTrue(canToggleLock(plain)); + assert.isFalse(canToggleLock(titled)); + }); + + test('clearing a rule restores the permissive default', function () { + const note = new NoteComment(this.workspace); + setLockPermission(this.workspace, () => false); + clearLockPermission(this.workspace); + assert.isTrue(canToggleLock(note)); + }); + + // The reason this is held per workspace rather than captured once at + // registration, the way the palette is: a host running an authoring + // workspace beside a restricted one must not have the first one's + // permissions decide for both. + test('a rule does not leak to another workspace', function () { + const other = new Blockly.Workspace(); + try { + setLockPermission(this.workspace, () => false); + assert.isFalse(canToggleLock(new NoteComment(this.workspace))); + assert.isTrue(canToggleLock(new NoteComment(other))); + } finally { + clearLockPermission(other); + other.dispose(); + } + }); + + suite('through the plugin', function () { + test('two instances keep their own rules', function () { + const other = new Blockly.Workspace(); + const mine = new WorkspaceNotes(this.workspace, { + contextMenu: false, + canToggleLock: () => false, + }); + const theirs = new WorkspaceNotes(other, {contextMenu: false}); + try { + mine.init(); + theirs.init(); + assert.isFalse(canToggleLock(new NoteComment(this.workspace))); + assert.isTrue(canToggleLock(new NoteComment(other))); + } finally { + mine.dispose(); + theirs.dispose(); + other.dispose(); + } + }); + + test('disposing forgets the rule', function () { + const plugin = new WorkspaceNotes(this.workspace, { + contextMenu: false, + canToggleLock: () => false, + }); + plugin.init(); + assert.isFalse(canToggleLock(new NoteComment(this.workspace))); + plugin.dispose(); + assert.isTrue(canToggleLock(new NoteComment(this.workspace))); + }); + }); +}); diff --git a/blockly-workspace-notes/test/note.mocha.js b/blockly-workspace-notes/test/note.mocha.js index a788c51..0577142 100644 --- a/blockly-workspace-notes/test/note.mocha.js +++ b/blockly-workspace-notes/test/note.mocha.js @@ -28,6 +28,7 @@ suite('Note model', function () { assert.equal(note.getTitle(), ''); assert.equal(note.getColour(), DEFAULT_COLOUR); assert.isFalse(note.isPinned()); + assert.isFalse(note.isLocked()); assert.equal(note.getZIndex(), 0); }); @@ -135,7 +136,7 @@ suite('Note model', function () { }); suite('pinning', function () { - test('pinning locks the note in place', function () { + test('pinning holds the note in place', function () { const note = new NoteComment(this.workspace); assert.isTrue(note.isOwnMovable()); note.setPinned(true); @@ -152,6 +153,59 @@ suite('Note model', function () { }); }); + suite('locking', function () { + test('locking makes the note read-only and undeletable', function () { + const note = new NoteComment(this.workspace); + assert.isTrue(note.isOwnEditable()); + assert.isTrue(note.isOwnDeletable()); + note.setLocked(true); + assert.isTrue(note.isLocked()); + assert.isFalse(note.isOwnEditable()); + assert.isFalse(note.isOwnDeletable()); + }); + + test('unlocking restores both', function () { + const note = new NoteComment(this.workspace); + note.setLocked(true); + note.setLocked(false); + assert.isFalse(note.isLocked()); + assert.isTrue(note.isOwnEditable()); + assert.isTrue(note.isOwnDeletable()); + }); + + // Locking owns editable and deletable, pinning owns movable, and neither + // writes the other's. Without this the two can silently merge, and + // unpinning a locked note would hand its movement back. + test('locking never touches movability', function () { + const note = new NoteComment(this.workspace); + note.setLocked(true); + assert.isTrue(note.isOwnMovable()); + note.setLocked(false); + assert.isTrue(note.isOwnMovable()); + }); + + test('locking and pinning compose', function () { + const note = new NoteComment(this.workspace); + note.setPinned(true); + note.setLocked(true); + assert.isFalse(note.isOwnMovable()); + assert.isFalse(note.isOwnEditable()); + assert.isFalse(note.isOwnDeletable()); + + note.setLocked(false); + assert.isTrue(note.isPinned()); + assert.isFalse(note.isOwnMovable()); + assert.isTrue(note.isOwnEditable()); + assert.isTrue(note.isOwnDeletable()); + }); + + test('saveNoteState carries the flag', function () { + const note = new NoteComment(this.workspace); + note.setLocked(true); + assert.isTrue(note.saveNoteState().locked); + }); + }); + suite('stacking helpers', function () { test('nextZIndex is one past the highest in use', function () { assert.equal(nextZIndex(this.workspace), 1); @@ -177,16 +231,34 @@ suite('Note model', function () { title: 'All', colour: '#c7e4ff', pinned: true, + locked: true, zIndex: 3, meta: {author: 'x', createdAt: 'a', updatedAt: 'b'}, }); assert.equal(this.note.getTitle(), 'All'); assert.equal(this.note.getColour(), '#c7e4ff'); assert.isTrue(this.note.isPinned()); + assert.isTrue(this.note.isLocked()); assert.equal(this.note.getZIndex(), 3); assert.equal(this.note.getMeta().author, 'x'); }); + // A state object written before locking existed has no `locked` key at + // all. It has to read as false, not undefined, or it survives into + // saveNoteState and the change comparison stops seeing a real lock. + test("'*' without a locked key reads as unlocked", function () { + this.note.setLocked(true); + this.note.applyNoteProperty('*', { + title: '', + colour: DEFAULT_COLOUR, + pinned: false, + zIndex: 0, + meta: {author: '', createdAt: 'a', updatedAt: 'b'}, + }); + assert.isFalse(this.note.isLocked()); + assert.isFalse(this.note.saveNoteState().locked); + }); + test("'*' with null is a no-op, so a delete snapshot replays", function () { this.note.setTitle('Keep me'); this.note.applyNoteProperty('*', null); diff --git a/blockly-workspace-notes/test/serializer.mocha.js b/blockly-workspace-notes/test/serializer.mocha.js index 7e7a0e3..9e3f9e3 100644 --- a/blockly-workspace-notes/test/serializer.mocha.js +++ b/blockly-workspace-notes/test/serializer.mocha.js @@ -54,6 +54,7 @@ suite('Note serialization', function () { if (overrides.colour) note.setColour(overrides.colour); if (overrides.zIndex) note.setZIndex(overrides.zIndex); if (overrides.pinned) note.setPinned(true); + if (overrides.locked) note.setLocked(true); if (overrides.collapsed) note.setCollapsed(true); note.moveTo( new Blockly.utils.Coordinate(overrides.x ?? 0, overrides.y ?? 0), @@ -97,6 +98,7 @@ suite('Note serialization', function () { 'colour', 'collapsed', 'pinned', + 'locked', 'zIndex', 'editable', 'movable', @@ -132,6 +134,14 @@ suite('Note serialization', function () { assert.notProperty(saved, 'movable'); }); + test('a locked note omits the two flags it implies', function () { + const note = makeNote(this.workspace, {locked: true}); + const saved = saveNote(note, {saveIds: true}); + assert.isTrue(saved.locked); + assert.notProperty(saved, 'editable'); + assert.notProperty(saved, 'deletable'); + }); + test('the default colour is treated as "no colour"', function () { const note = makeNote(this.workspace); note.setColour(DEFAULT_COLOUR); @@ -181,12 +191,19 @@ suite('Note serialization', function () { height: 160, }); makeNote(this.workspace, { - text: 'Locked', + text: 'Held in place', pinned: true, collapsed: true, x: 400, y: 20, }); + makeNote(this.workspace, { + text: 'Read only', + title: 'Brief', + locked: true, + x: 400, + y: 220, + }); const first = Blockly.serialization.workspaces.save(this.workspace); @@ -331,6 +348,13 @@ suite('Note serialization', function () { assert.isAbove(note.getSize().width, 0, 'width must not collapse'); }); + test('a locked note loads locked', function () { + const note = appendNote({locked: true}, this.workspace); + assert.isTrue(note.isLocked()); + assert.isFalse(note.isOwnEditable()); + assert.isFalse(note.isOwnDeletable()); + }); + test('an absent coordinate is not RTL-flipped', function () { // The flip converts a saved x into a workspace x, so it only applies to // a value the file actually carried. Applying it to the fallback sent @@ -452,6 +476,22 @@ suite('Note serialization', function () { assert.equal(note.getTitle(), 'Before'); }); + test('undoing a lock releases the note again', async function () { + const note = makeNote(this.workspace); + await flushEvents(); + + note.setLocked(true); + await flushEvents(); + assert.isFalse(note.isOwnEditable()); + assert.isFalse(note.isOwnDeletable()); + + this.workspace.undo(false); + await flushEvents(); + assert.isFalse(note.isLocked()); + assert.isTrue(note.isOwnEditable()); + assert.isTrue(note.isOwnDeletable()); + }); + test('undoing a colour change reverts it', async function () { const note = makeNote(this.workspace); await flushEvents(); diff --git a/blockly-workspace-notes/test/xml.mocha.js b/blockly-workspace-notes/test/xml.mocha.js index 9a343cf..7e6a7d7 100644 --- a/blockly-workspace-notes/test/xml.mocha.js +++ b/blockly-workspace-notes/test/xml.mocha.js @@ -47,6 +47,7 @@ suite('Note XML serialization', function () { */ function makeNote(workspace, overrides = {}) { const note = new NoteComment(workspace); + if (overrides.locked) note.setLocked(true); if (overrides.text) note.setText(overrides.text); if (overrides.title) note.setTitle(overrides.title); if (overrides.colour) note.setColour(overrides.colour); @@ -93,7 +94,7 @@ suite('Note XML serialization', function () { test('a plain note writes no note-specific attributes', function () { makeNote(this.workspace); const [elem] = comments(Blockly.Xml.workspaceToDom(this.workspace)); - for (const name of ['title', 'colour', 'pinned', 'z']) { + for (const name of ['title', 'colour', 'pinned', 'locked', 'z']) { assert.isNull( elem.getAttribute(name), `expected ${name} to be omitted`, @@ -155,7 +156,7 @@ suite('Note XML serialization', function () { assert.equal(note.getSize().width, 250); }); - test('a pinned note comes back locked', function () { + test('a pinned note comes back held in place', function () { const dom = Blockly.utils.xml.textToDom( '', @@ -166,6 +167,18 @@ suite('Note XML serialization', function () { assert.isFalse(note.isOwnMovable()); }); + test('a locked note comes back read-only and undeletable', function () { + const dom = Blockly.utils.xml.textToDom( + '', + ); + Blockly.Xml.domToWorkspace(dom, this.workspace); + const note = this.workspace.getCommentById('n1'); + assert.isTrue(note.isLocked()); + assert.isFalse(note.isOwnEditable()); + assert.isFalse(note.isOwnDeletable()); + }); + test('a plain old still loads, with defaults', function () { const dom = Blockly.utils.xml.textToDom( 'legacy' + @@ -351,7 +364,7 @@ suite('Note XML serialization', function () { const dom = Blockly.utils.xml.textToDom( 'body', ); const [elem] = comments(dom); @@ -369,6 +382,7 @@ suite('Note XML serialization', function () { title: 'T', colour: '#ffd6a5', pinned: true, + locked: true, zIndex: 7, meta: {author: 'ada', createdAt: 'c', updatedAt: 'u'}, }); From 823e8b4ceebb382295c3eb872ed522198e76abc1 Mon Sep 17 00:00:00 2001 From: Preet Vadaliya Date: Wed, 9 Sep 2026 11:03:49 +0200 Subject: [PATCH 17/20] docs: show locking in the note anatomy diagram The diagram never labelled the title, and predated the marker slot. It now has two sections: the parts of an ordinary note, and a locked one beside callouts for the padlock and for the two controls that go missing. --- blockly-workspace-notes/README.md | 2 +- .../docs/images/note-anatomy.svg | 129 ++++++++++++++---- blockly-workspace-notes/docs/using-notes.md | 2 +- 3 files changed, 104 insertions(+), 29 deletions(-) diff --git a/blockly-workspace-notes/README.md b/blockly-workspace-notes/README.md index 69ed311..966554b 100644 --- a/blockly-workspace-notes/README.md +++ b/blockly-workspace-notes/README.md @@ -6,7 +6,7 @@ Sticky notes for a Blockly workspace: draggable, resizable, colour-coded cards with a title, an author and a stacking order.

- The parts of a note: title bar, collapse and delete buttons, title, body, author and date, and resize handle + The parts of a note: title bar, collapse and delete buttons, title, body, author and date, and resize handle; and below it a locked note with a padlock, no delete button and no resize handle

Notes extend Blockly's own workspace comments, so dragging, resizing, diff --git a/blockly-workspace-notes/docs/images/note-anatomy.svg b/blockly-workspace-notes/docs/images/note-anatomy.svg index 166d960..b7025e8 100644 --- a/blockly-workspace-notes/docs/images/note-anatomy.svg +++ b/blockly-workspace-notes/docs/images/note-anatomy.svg @@ -1,13 +1,16 @@ - - The parts of a note - A note at its default size of 260 by 180, with its title bar, collapse and delete buttons, title, body and resize handle labelled. + + The parts of a note, and what locking changes + Two notes at the default size of 260 by 180. The first labels the collapse button, the title, the delete button, the body, the author and date line, and the resize handle. The second is locked: it shows a padlock in the marker slot, and has no delete button and no resize handle. - + - - + The parts of a note + + + @@ -60,34 +63,106 @@ - - - - - + + + + + + - Collapse - folds the note to its bar + Title + click it to rename, in place + + Collapse + folds the note to its bar - Author and date - recorded automatically + Author and date + recorded automatically - Delete - one undo brings it back + Delete + one undo brings it back - The body - written in the note's own ink + The body + written in the note's own ink - Resize handle - inside the sheet, not on its corner + Resize handle + inside the sheet, not on its corner - - - + + + + + the title bar, one step darker than the body + + What locking changes + + + + + + + + + + + + + + + + + Read the brief + + + Written by your teacher. + + + + + + + + + ada + + + + + + + + 9 Sep 2026 + + + + + + + + + + + Marker slot + a padlock here, or a pin when pinned + + The body + read-only, and the title with it + + No delete button + and no Delete in the menu + + No resize handle + the note keeps the size it was left - the title bar, one step darker than the body diff --git a/blockly-workspace-notes/docs/using-notes.md b/blockly-workspace-notes/docs/using-notes.md index c5afc12..bf8661d 100644 --- a/blockly-workspace-notes/docs/using-notes.md +++ b/blockly-workspace-notes/docs/using-notes.md @@ -4,7 +4,7 @@ A note is a card on the workspace: a title bar you can read at a glance, a colour of its own, and room to write underneath.

- The parts of a note: title bar, collapse and delete buttons, title, body, author and date, and resize handle + The parts of a note: title bar, collapse and delete buttons, title, body, author and date, and resize handle; and below it a locked note with a padlock, no delete button and no resize handle

The title bar carries two buttons — collapse on the left, delete on the right — From bf249db6db09f23a9211abf362c174be4d4694a8 Mon Sep 17 00:00:00 2001 From: Preet Vadaliya Date: Wed, 9 Sep 2026 11:45:47 +0200 Subject: [PATCH 18/20] refactor: remove the circular import in model/ Recognise a note by a mark the mixin puts on it rather than by instanceof, so utils/guards no longer imports the note classes - that import was what closed the loop. Raising a note now goes through a bringToFront seam, so stacking needs nothing from model/ at all. The cycle was benign but dishonest: the production bundle flattens those modules into one scope while the test bundle keeps them apart, so a cycle that broke would have broken only in dist/. A test now walks the graph. The mark is a Symbol.for, so two bundled copies of the plugin recognise each other's notes, which instanceof never could. --- blockly-workspace-notes/docs/design.md | 17 ++- .../src/constants/brand.ts | 27 ++++ blockly-workspace-notes/src/model/note.ts | 5 + .../src/model/note_mixin.ts | 25 ++++ blockly-workspace-notes/src/model/stacking.ts | 24 ++-- blockly-workspace-notes/src/types/note.ts | 1 + blockly-workspace-notes/src/utils/guards.ts | 30 ++++- blockly-workspace-notes/test/imports.mocha.js | 118 ++++++++++++++++++ blockly-workspace-notes/test/note.mocha.js | 26 ++++ 9 files changed, 250 insertions(+), 23 deletions(-) create mode 100644 blockly-workspace-notes/src/constants/brand.ts create mode 100644 blockly-workspace-notes/test/imports.mocha.js diff --git a/blockly-workspace-notes/docs/design.md b/blockly-workspace-notes/docs/design.md index 554687f..e9f11f8 100644 --- a/blockly-workspace-notes/docs/design.md +++ b/blockly-workspace-notes/docs/design.md @@ -184,7 +184,16 @@ Four rules keep it that way: so the side effects are findable in one sweep and every one has a matching `unregister`. -One import cycle exists on purpose: `model/note.ts` and `model/stacking.ts` -need each other, since a note restacks its neighbours when its z-index changes -and restacking needs the class to recognise a note. Both uses sit inside -function bodies, so neither runs while the modules are still evaluating. +The graph is acyclic, and a test says so. `test/imports.mocha.js` walks every +file and fails on any loop, because a cycle here would not fail honestly: the +production bundle flattens modules into one scope, where a cycle becomes an +ordering problem, while the test bundle keeps them apart and papers over it. A +cycle that broke would break only in `dist/`. + +The thing that keeps it acyclic is small and easy to undo by accident. A note +restacks its neighbours when its z-index changes, so `model/note.ts` imports +`model/stacking.ts`; for the graph to stay linear, nothing under that may +import a note class back. So `restackNotes` recognises a note by a mark the +mixin puts on it (`constants/brand.ts`) rather than by `instanceof`, and raises +it through a `bringToFront` seam rather than by reaching for the rendered +class. Both of those exist for this reason and no other. diff --git a/blockly-workspace-notes/src/constants/brand.ts b/blockly-workspace-notes/src/constants/brand.ts new file mode 100644 index 0000000..55c4abe --- /dev/null +++ b/blockly-workspace-notes/src/constants/brand.ts @@ -0,0 +1,27 @@ +/** + * @fileoverview The mark that says an object is one of ours. + * + * `utils/guards.ts` used to recognise a note with `instanceof`, which meant it + * had to import both note classes — and that import is what closed the loop + * between `model/note.ts`, `model/stacking.ts` and `utils/guards.ts`. Reading + * a mark off the object instead lets the guard sit at the bottom of the graph + * with nothing beneath it, which is what keeps the whole graph acyclic. + * + * A symbol rather than a string, so it cannot collide with a property a host + * has put on its own comments, and so it stays out of `Object.keys` and + * `JSON.stringify` — which matters here, because `changeNoteProperty_` + * compares note state by stringifying it. + * + * `Symbol.for` rather than `Symbol()`, so two bundled copies of this plugin + * recognise each other's notes. `instanceof` never could: a host that ends up + * with the plugin twice would have had one copy's notes silently vanish from a + * save written by the other. The version segment is the other half of that + * bargain — it stops two copies at *different* versions accepting each other, + * which would be worse. Bump it only when `NoteSurface` changes in a way an + * older copy could not handle. + */ + +/** Marks an object as a note. See the note on `Symbol.for` above. */ +export const NOTE_BRAND = Symbol.for( + '@mit-app-inventor/blockly-workspace-notes/note@1', +); diff --git a/blockly-workspace-notes/src/model/note.ts b/blockly-workspace-notes/src/model/note.ts index b71dc8d..de1ccf4 100644 --- a/blockly-workspace-notes/src/model/note.ts +++ b/blockly-workspace-notes/src/model/note.ts @@ -635,6 +635,11 @@ export class Note extends RenderedNoteBase { restackNotes(this.workspace); } + /** Raises this note above its neighbours, by moving it in the DOM. */ + bringToFront() { + this.view.bringToFront(); + } + /** * Includes the note's extra state in clipboard data so that duplicate and * paste keep the title and colour. Consumed by NotePaster. diff --git a/blockly-workspace-notes/src/model/note_mixin.ts b/blockly-workspace-notes/src/model/note_mixin.ts index c603753..dba3ec0 100644 --- a/blockly-workspace-notes/src/model/note_mixin.ts +++ b/blockly-workspace-notes/src/model/note_mixin.ts @@ -15,6 +15,7 @@ import * as Blockly from 'blockly/core'; +import {NOTE_BRAND} from '../constants/brand'; import {DEFAULT_COLOUR} from '../constants/colours'; import {NoteChange} from '../events/note_change'; import {asOneUndoStep} from '../utils/undo'; @@ -56,6 +57,20 @@ const NoteMixin = (Base: TBase) => */ protected noteState_?: NoteState; + /** + * Marks this object as a note, for `utils/guards.isNote`. + * + * An accessor rather than a class field, so it lives on the prototype: it + * exists from the moment the class does, costs nothing per instance, and + * is non-enumerable where an own field would not be - which keeps it out + * of the `JSON.stringify` comparison in `changeNoteProperty_`. + * + * @returns Always true. + */ + get [NOTE_BRAND](): true { + return true; + } + /** * Returns this note's extra state, creating it on first access. * @@ -339,6 +354,16 @@ const NoteMixin = (Base: TBase) => /** Applies the stacking order. Overridden by the rendered subclass. */ applyZIndex(): void {} + /** + * Raises this note above its neighbours. + * + * A no-op here: raising something means moving it in the DOM, and a + * headless note has none. `restackNotes` calls this on every note it + * sorts, so the seam is what lets it work without knowing which kind it + * has in front of it. + */ + bringToFront(): void {} + /** * Disposes of the note. * diff --git a/blockly-workspace-notes/src/model/stacking.ts b/blockly-workspace-notes/src/model/stacking.ts index 668a536..c0d2131 100644 --- a/blockly-workspace-notes/src/model/stacking.ts +++ b/blockly-workspace-notes/src/model/stacking.ts @@ -6,16 +6,15 @@ * below are what a caller uses to put a note in front of, or behind, * everything already on the workspace. * - * This module and `model/note.ts` import each other: `Note.applyZIndex` calls - * `restackNotes`, and `restackNotes` needs the class to recognise a note. Both - * uses are inside function bodies, so neither runs during module evaluation - * and the cycle resolves. Keep it that way — a top-level use of `Note` here - * would hit the temporal dead zone. + * Nothing here imports from `model/`, deliberately. `Note.applyZIndex` calls + * `restackNotes`, so an import in the other direction would be a cycle; the + * three things this module needs from a note - recognising one, reading its + * z-index, and raising it - are all reached without naming the class, through + * `isNote` and the `bringToFront` seam. */ import * as Blockly from 'blockly/core'; -import {Note} from './note'; import {isNote} from '../utils/guards'; /** @@ -26,15 +25,14 @@ import {isNote} from '../utils/guards'; */ export function restackNotes(workspace: Blockly.Workspace): void { if (!workspace.rendered) return; - const notes = workspace + // Two passes rather than one predicate: combining them would need the + // narrowed type spelled out, which means naming the class again. + workspace .getTopComments(false) - .filter( - (comment): comment is Note => - comment instanceof Note && !comment.isDeadOrDying(), - ); - notes + .filter(isNote) + .filter((note) => !note.isDeadOrDying()) .sort((a, b) => a.getZIndex() - b.getZIndex()) - .forEach((note) => note.view.bringToFront()); + .forEach((note) => note.bringToFront()); } /** diff --git a/blockly-workspace-notes/src/types/note.ts b/blockly-workspace-notes/src/types/note.ts index 7674159..5ce8bfd 100644 --- a/blockly-workspace-notes/src/types/note.ts +++ b/blockly-workspace-notes/src/types/note.ts @@ -81,4 +81,5 @@ export interface NoteSurface { applyPinned(): void; applyLocked(): void; applyZIndex(): void; + bringToFront(): void; } diff --git a/blockly-workspace-notes/src/utils/guards.ts b/blockly-workspace-notes/src/utils/guards.ts index da4ff57..a095d96 100644 --- a/blockly-workspace-notes/src/utils/guards.ts +++ b/blockly-workspace-notes/src/utils/guards.ts @@ -5,18 +5,36 @@ * plain one alongside its notes, so every place that walks the comment list * narrows it through here first. * - * The check is `instanceof` against both concrete classes rather than a duck - * type: a plain comment carrying a `title` property should not be mistaken for - * a note. + * The check reads the mark `model/note_mixin.ts` puts on every note, rather + * than testing `instanceof` against the two classes. Two reasons. It keeps + * this module at the bottom of the import graph - importing the classes here + * is what used to close the loop through `model/stacking.ts` - and a symbol + * read is not defeated by the plugin being bundled twice, which `instanceof` + * is. It is still not duck typing: a plain comment carrying a `title` property + * has no way to be carrying `NOTE_BRAND` as well. + * + * The classes are imported for their types alone, and that import must stay + * written as `import type`: relying on TypeScript to elide it would put the + * cycle back the day someone turns on `verbatimModuleSyntax`. + * + * Note that `ui/context_menu.ts` and `clipboard/note_paster.ts` still use + * `instanceof Note` directly, and so are narrower than this - they want the + * rendered class specifically. The two have never agreed anyway: a headless + * `NoteComment` passes `isNote` and fails `instanceof Note`. */ -import {Note} from '../model/note'; -import {NoteComment} from '../model/note_comment'; +import {NOTE_BRAND} from '../constants/brand'; +import type {Note} from '../model/note'; +import type {NoteComment} from '../model/note_comment'; /** * @param candidate Any value. * @returns Whether the value is a note. */ export function isNote(candidate: unknown): candidate is Note | NoteComment { - return candidate instanceof Note || candidate instanceof NoteComment; + return ( + typeof candidate === 'object' && + candidate !== null && + (candidate as Record)[NOTE_BRAND] === true + ); } diff --git a/blockly-workspace-notes/test/imports.mocha.js b/blockly-workspace-notes/test/imports.mocha.js new file mode 100644 index 0000000..3c013af --- /dev/null +++ b/blockly-workspace-notes/test/imports.mocha.js @@ -0,0 +1,118 @@ +/** + * @fileoverview Asserts that `src/` has no circular imports. + * + * There used to be one, between `model/note.ts`, `model/stacking.ts` and + * `utils/guards.ts`, and it was documented as harmless. It was — but only by + * accident, and the two builds resolved it by different means: the production + * bundle flattens those modules into one scope, where a cycle is raw temporal + * dead zone ordering, while the test bundle keeps module boundaries and uses + * live bindings. So a cycle that broke would have broken in `dist/` and passed + * here. This test is what stops one coming back unnoticed. + * + * Only *value* imports count. `import type` is erased before the bundler ever + * sees it, so a type-level loop is not a cycle in any build. + * + * Deliberately conservative: a mixed `import {type Foo, Bar}` is read as a + * value edge. A false positive fails loudly and is fixed by splitting the + * import; a false negative would be invisible, which is the failure this test + * exists to prevent. + * + * Known blind spots, none of which the source uses today: `export * from`, + * dynamic `import()`, and path aliases. + */ + +import {assert} from 'chai'; +import * as fs from 'fs'; +import * as path from 'path'; + +/** The source tree, resolved from the package root. */ +const SRC = path.join(process.cwd(), 'src'); + +/** + * @param dir A directory to walk. + * @returns Every `.ts` file under it, recursively. + */ +function walk(dir) { + return fs.readdirSync(dir, {withFileTypes: true}).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return walk(full); + return entry.isFile() && full.endsWith('.ts') ? [full] : []; + }); +} + +/** + * Removes comments, so that an import statement quoted in a fileoverview is + * not mistaken for a real one. Several of them do exactly that. + * + * @param source A TypeScript source file. + * @returns The same source with block and line comments blanked. + */ +function stripComments(source) { + return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, ''); +} + +/** + * @param file An absolute path to a source file. + * @returns The files it imports at runtime, as absolute paths. + */ +function valueImportsOf(file) { + const source = stripComments(fs.readFileSync(file, 'utf8')) + // Whole type-only statements, which the compiler erases. + .replace(/\b(?:import|export)\s+type\s+[\s\S]*?from\s*['"][^'"]+['"]/g, ''); + + const specifiers = [ + // import â€Ļ from './x' / export â€Ļ from './x' + ...source.matchAll(/(?:import|export)\b[^;]*?from\s*['"](\.[^'"]+)['"]/g), + // import './x' — side-effect only, as plugin.ts does for the stylesheet. + ...source.matchAll(/import\s*['"](\.[^'"]+)['"]/g), + ].map((match) => match[1]); + + return specifiers + .map((specifier) => { + const base = path.resolve(path.dirname(file), specifier); + for (const candidate of [`${base}.ts`, path.join(base, 'index.ts')]) { + if (fs.existsSync(candidate)) return candidate; + } + return null; + }) + .filter((resolved) => resolved !== null); +} + +suite('Import graph', function () { + suiteSetup(function () { + // Without this, a wrong working directory yields an empty walk, no + // cycles, and a green test that checked nothing. + assert.isTrue( + fs.existsSync(path.join(SRC, 'index.ts')), + `expected the source tree at ${SRC}`, + ); + this.files = walk(SRC); + assert.isAbove(this.files.length, 30, 'the walk found too few files'); + }); + + test('no module imports itself, however indirectly', function () { + const graph = new Map( + this.files.map((file) => [file, valueImportsOf(file)]), + ); + const label = (file) => path.relative(SRC, file); + + const state = new Map(); // file -> 'open' | 'done' + const cycles = []; + + const visit = (file, trail) => { + if (state.get(file) === 'done') return; + if (state.get(file) === 'open') { + const loop = trail.slice(trail.indexOf(file)); + cycles.push([...loop, file].map(label).join(' -> ')); + return; + } + state.set(file, 'open'); + for (const next of graph.get(file) ?? []) visit(next, [...trail, file]); + state.set(file, 'done'); + }; + + for (const file of this.files) visit(file, []); + + assert.deepEqual(cycles, [], `circular imports:\n ${cycles.join('\n ')}`); + }); +}); diff --git a/blockly-workspace-notes/test/note.mocha.js b/blockly-workspace-notes/test/note.mocha.js index 0577142..d43ee70 100644 --- a/blockly-workspace-notes/test/note.mocha.js +++ b/blockly-workspace-notes/test/note.mocha.js @@ -12,6 +12,7 @@ import {assert} from 'chai'; import {DEFAULT_COLOUR, NoteChange, NoteComment, isNote} from '../src/index'; import {nextZIndex, previousZIndex} from '../src/model/stacking'; +import {NOTE_BRAND} from '../src/constants/brand'; suite('Note model', function () { setup(function () { @@ -153,6 +154,31 @@ suite('Note model', function () { }); }); + // `isNote` reads this mark instead of testing instanceof, and the mixin's + // `as new (...) => ...` cast means the brand reaches no .d.ts at all - so + // nothing type-checks that it is still there. Were it dropped, `isNote` + // would return false for everything and the serializer would write an empty + // payload without throwing. + suite('the note brand', function () { + test('every note carries it', function () { + assert.isTrue(new NoteComment(this.workspace)[NOTE_BRAND]); + assert.isFalse(isNote({title: 'not a note'})); + assert.isFalse(isNote(null)); + }); + + test('it stays a prototype accessor, not an own field', function () { + const note = new NoteComment(this.workspace); + assert.notProperty( + Object.getOwnPropertyDescriptors(note), + NOTE_BRAND, + 'an own field would be enumerable, and would land after super()', + ); + const mixin = Object.getPrototypeOf(NoteComment.prototype); + const descriptor = Object.getOwnPropertyDescriptor(mixin, NOTE_BRAND); + assert.isFunction(descriptor?.get, 'the brand must be a getter'); + }); + }); + suite('locking', function () { test('locking makes the note read-only and undeletable', function () { const note = new NoteComment(this.workspace); From 0f44598d046e7480fe7177fc021586469f0238cb Mon Sep 17 00:00:00 2001 From: Preet Vadaliya Date: Wed, 9 Sep 2026 11:48:05 +0200 Subject: [PATCH 19/20] refactor: name every number the note draws layout.ts said nothing in the chrome should introduce an ad-hoc number, while css.ts carried ten of them - the card border and its pinned doubling, the glyph stroke in three places, two fades, the focus ring and the selection weight. Three comments named the wrong source: the line box is core's field border rect, not its icon, which is 17; the scrollbar's prose described arithmetic giving 5 for a value of 8; and the footer's 11 is px where core's identical-looking 11 is pt. The footer glyph now derives from the line box. The font size deliberately does not - three quarters of the body's 14.667px is 11.00025, which would reach the stylesheet verbatim. dist/index.js is byte-identical, so nothing moved. --- .../src/constants/layout.ts | 134 +++++++++++++++--- blockly-workspace-notes/src/ui/css.ts | 31 ++-- blockly-workspace-notes/src/ui/icons.ts | 4 +- blockly-workspace-notes/test/layout.mocha.js | 74 ++++++++++ 4 files changed, 215 insertions(+), 28 deletions(-) create mode 100644 blockly-workspace-notes/test/layout.mocha.js diff --git a/blockly-workspace-notes/src/constants/layout.ts b/blockly-workspace-notes/src/constants/layout.ts index 7ae2ad8..8c86ab2 100644 --- a/blockly-workspace-notes/src/constants/layout.ts +++ b/blockly-workspace-notes/src/constants/layout.ts @@ -1,10 +1,18 @@ /** * @fileoverview The geometry a note's chrome is built from. * - * Every number here is derived from one of two sources: Blockly's own renderer - * scale, so a note sits comfortably beside the blocks it annotates, or - * `TITLE_LINE_HEIGHT`, which is the single unit the note's own layout is - * measured in. Nothing in the chrome should introduce an ad-hoc number. + * Every number the note draws is named here, and every one says where it came + * from: Blockly's renderer scale, so a note sits comfortably beside the blocks + * it annotates; `TITLE_LINE_HEIGHT`, the single unit the note's own layout is + * measured in; or core's own stylesheet, where the point is to match it. A + * handful - the fades and the stroke weights - are simply chosen, and say so. + * The rule is that the number is named and sourced, not that it is derived; + * inventing a derivation for a value that has none only hides it better. + * + * The scope is the note's SVG chrome. `ui/colour_swatches.ts` draws HTML + * inside Blockly's context menu and follows core's menu scale instead, which + * is a different set of numbers - 4, 6, 15, 28 - and is deliberately not + * gathered here. */ /** @@ -16,12 +24,18 @@ export const SMALL_PADDING = 3; /** The middle step of Blockly's padding scale. */ export const MEDIUM_PADDING = 5; +/** The step between medium and large. */ +export const MEDIUM_LARGE_PADDING = 8; + /** The largest step of Blockly's padding scale. */ export const LARGE_PADDING = 10; /** - * Height reserved for one line of title text. Blockly's own icon size, which - * is the box a single line of field text is laid out in. + * Height reserved for one line of title text. + * + * Core's `FIELD_BORDER_RECT_HEIGHT`: the box a single line of field text is + * laid out in, and the closest thing Blockly has to a line box. Not its icon + * size, which is 17. */ export const TITLE_LINE_HEIGHT = 16; @@ -150,10 +164,13 @@ export const BAR_DELETE_NUDGE = BAR_ICON_MARGIN; /** * Width of the body's scrollbar, for engines styled through - * ::-webkit-scrollbar. Half of `SMALL_PADDING` either side of a 2px thumb - - * narrow enough to read as a mark on the paper rather than as a control. + * ::-webkit-scrollbar. + * + * A step on Blockly's own scale, and the narrowest one that still leaves the + * thumb something to sit in - narrow enough to read as a mark on the paper + * rather than as a control. */ -export const SCROLLBAR_WIDTH = 8; +export const SCROLLBAR_WIDTH = MEDIUM_LARGE_PADDING; /** Space between a note's edge and its writing area. */ export const BODY_INSET = NOTE_MARGIN; @@ -176,10 +193,27 @@ export const DEFAULT_SIZE = {width: 260, height: 180}; */ export const FOOTER_HEIGHT = TITLE_LINE_HEIGHT; -/** The glyphs in the footer, smaller than the bar's since the text is too. */ -export const FOOTER_ICON_SIZE = 12; +/** + * The glyphs in the footer, smaller than the bar's since the text is too. + * + * Three quarters of the bar's, which is the ratio the footer type takes + * against the body type - so the two rows shrink by the same step. + */ +export const FOOTER_ICON_SIZE = TITLE_LINE_HEIGHT * 0.75; -/** Footer type size: small enough to read as a caption, not as content. */ +/** + * Footer type size, in px: small enough to read as a caption, not as content. + * + * The same three-quarter step as the glyphs, against a body that inherits + * core's field text - but written out rather than derived, because that step + * is not exact here. Core's size is 11 *pt*, which is 14.667px, and three + * quarters of it is 11.00025. Writing the multiplication would put + * `font-size: 11.00025px` in the stylesheet. + * + * Worth knowing that core's `FIELD_TEXT_FONTSIZE` is also written `11`. The + * two look like the same number and are not: this one is px, that one is pt, + * and the footer is three quarters of the body rather than the same size. + */ export const FOOTER_FONT_SIZE = 11; /** Between a footer glyph and the text it labels. */ @@ -197,14 +231,24 @@ export const MIN_FOOTER_AUTHOR_CHARS = 3; /** Between the author and the date. */ export const FOOTER_ITEM_GAP = LARGE_PADDING; +/** + * The box core draws the resize handle in. + * + * Core's own number, from its comment stylesheet, repeated here so the footer + * knows how much room to leave it. It equals `FOOTER_ICON_SIZE` today, which + * is a coincidence and not a reason to share one constant - the footer's + * glyphs could change size without moving core's handle. + */ +export const RESIZE_HANDLE_SIZE = 12; + /** * How much of the footer's trailing end the resize handle claims. * - * Core's handle is 12px and the stylesheet pulls it a body inset in from the - * corner, which puts it squarely in the footer's row. The footer stops short of - * it rather than running underneath. + * The stylesheet pulls the handle a body inset in from the corner, which puts + * it squarely in the footer's row. The footer stops short of it rather than + * running underneath. */ -export const FOOTER_HANDLE_CLEARANCE = 12 + LARGE_PADDING; +export const FOOTER_HANDLE_CLEARANCE = RESIZE_HANDLE_SIZE + LARGE_PADDING; /** * The smallest a note can be resized to. @@ -228,3 +272,61 @@ export const MIN_SIZE = { width: NOTE_MARGIN * 2 + TITLE_LINE_HEIGHT * 4, height: TOPBAR_HEIGHT + TITLE_LINE_HEIGHT + NOTE_MARGIN, }; + +/** + * The card's border, and the heavier one a pinned note gets. + * + * One pixel is Blockly's weight for a background edge - core uses it on the + * workspace and the mutator - and doubling it is the whole of the pinned + * note's second signal, beside the marker in its bar. + */ +export const CARD_BORDER_WIDTH = 1; + +/** See `CARD_BORDER_WIDTH`. */ +export const PINNED_CARD_BORDER_WIDTH = CARD_BORDER_WIDTH * 2; + +/** + * The stroke every glyph is authored at, on the 24-unit grid. + * + * Tabler's own weight. It is scaled down with the artwork, so a bar marker + * lands at about 1.3px and a footer glyph at exactly 1 - which is why the two + * rows read as the same family at two sizes rather than as two weights. + */ +export const GLYPH_STROKE_WIDTH = 2; + +/** + * The fade on a placeholder: the untitled heading, and the empty body's + * prompt. + * + * Chosen, not derived. Far enough back to read as "nothing here yet" against + * the note's own ink, and no further, since it still has to be legible on the + * palest paper in the palette. + */ +export const PLACEHOLDER_OPACITY = 0.55; + +/** + * The fade on the footer, which is a caption rather than content. + * + * Matches core's own `.blocklyIconGroup` fade as of v13. It matches rather + * than derives from it - core could move without this following. + */ +export const FOOTER_OPACITY = 0.6; + +/** The focus ring drawn on the bar's buttons, which core styles none for. */ +export const OUTLINE_WIDTH = 2; + +/** See `OUTLINE_WIDTH`. */ +export const OUTLINE_OFFSET = 1; + +/** See `OUTLINE_WIDTH`. Core's radius for a small piece of chrome. */ +export const OUTLINE_RADIUS = 2; + +/** + * The selection ring's weight. + * + * Core's, from `.blocklySelected .blocklyCommentHighlight`. A selected note + * should look selected the same way everything else on the workspace does, so + * this tracks core deliberately - as does the colour, which is left to core's + * own `#fc3` rather than named here. + */ +export const SELECTION_STROKE_WIDTH = 3; diff --git a/blockly-workspace-notes/src/ui/css.ts b/blockly-workspace-notes/src/ui/css.ts index 32a455f..331c066 100644 --- a/blockly-workspace-notes/src/ui/css.ts +++ b/blockly-workspace-notes/src/ui/css.ts @@ -41,6 +41,15 @@ import { BAR_ICON_MARGIN, BAR_ICON_SIZE, BODY_INSET, + CARD_BORDER_WIDTH, + FOOTER_OPACITY, + GLYPH_STROKE_WIDTH, + OUTLINE_OFFSET, + OUTLINE_RADIUS, + OUTLINE_WIDTH, + PINNED_CARD_BORDER_WIDTH, + PLACEHOLDER_OPACITY, + SELECTION_STROKE_WIDTH, FOOTER_FONT_SIZE, FOOTER_HEIGHT, SCROLLBAR_WIDTH, @@ -57,7 +66,7 @@ Blockly.Css.register(` .${NOTE_CLASS} .blocklyCommentHighlight { fill: var(--commentFillColour); stroke: var(--commentBorderColour); - stroke-width: 1px; + stroke-width: ${CARD_BORDER_WIDTH}px; } /* @@ -69,7 +78,7 @@ Blockly.Css.register(` * pushed the marker to the very corner of the note. */ .${NOTE_CLASS}.${PINNED_CLASS} .blocklyCommentHighlight { - stroke-width: 2px; + stroke-width: ${PINNED_CARD_BORDER_WIDTH}px; } /* @@ -92,7 +101,7 @@ Blockly.Css.register(` display: none; fill: none; stroke: var(--noteInkColour); - stroke-width: 2px; + stroke-width: ${GLYPH_STROKE_WIDTH}px; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; @@ -205,9 +214,9 @@ Blockly.Css.register(` */ .${NOTE_CLASS} .blocklyFoldoutIcon:focus-visible, .${NOTE_CLASS} .blocklyDeleteIcon:focus-visible { - outline: 2px solid var(--noteInkColour); - outline-offset: 1px; - border-radius: 2px; + outline: ${OUTLINE_WIDTH}px solid var(--noteInkColour); + outline-offset: ${OUTLINE_OFFSET}px; + border-radius: ${OUTLINE_RADIUS}px; } /* @@ -363,7 +372,7 @@ Blockly.Css.register(` .${NOTE_CLASS}.blocklyComment:not(.${TITLED_CLASS}) .${TITLE_CLASS}.blocklyText { fill: var(--noteInkColour); - opacity: 0.55; + opacity: ${PLACEHOLDER_OPACITY}; } .blocklyRTL .${TITLE_CLASS} { @@ -413,7 +422,7 @@ Blockly.Css.register(` * --noteInkColour never reaches it. */ .blocklyNoteTitleInput::placeholder { - opacity: 0.55; + opacity: ${PLACEHOLDER_OPACITY}; } .blocklyRTL .blocklyNoteTitleInput { @@ -458,10 +467,10 @@ Blockly.Css.register(` .${NOTE_CLASS} .${FOOTER_CLASS} { fill: none; stroke: var(--noteInkColour); - stroke-width: 2px; + stroke-width: ${GLYPH_STROKE_WIDTH}px; stroke-linecap: round; stroke-linejoin: round; - opacity: 0.6; + opacity: ${FOOTER_OPACITY}; pointer-events: none; } @@ -509,6 +518,6 @@ Blockly.Css.register(` .blocklySelected.${NOTE_CLASS} .${SELECTION_CLASS} { stroke: #fc3; - stroke-width: 3px; + stroke-width: ${SELECTION_STROKE_WIDTH}px; } `); diff --git a/blockly-workspace-notes/src/ui/icons.ts b/blockly-workspace-notes/src/ui/icons.ts index 218a105..c7a6979 100644 --- a/blockly-workspace-notes/src/ui/icons.ts +++ b/blockly-workspace-notes/src/ui/icons.ts @@ -19,6 +19,8 @@ * on screen - heavy enough to hold its own beside a bold heading. */ +import {GLYPH_STROKE_WIDTH} from '../constants/layout'; + /** Tabler's `chevron-down`, for the collapse button. */ export const CHEVRON_GLYPH = ['M6 9l6 6l6 -6']; @@ -85,7 +87,7 @@ export const LOCK_GLYPH = [ export function glyphToDataUri(paths: string[], colour: string): string { const svg = `` + paths.map((d) => ``).join('') + ``; diff --git a/blockly-workspace-notes/test/layout.mocha.js b/blockly-workspace-notes/test/layout.mocha.js new file mode 100644 index 0000000..d9d8fce --- /dev/null +++ b/blockly-workspace-notes/test/layout.mocha.js @@ -0,0 +1,74 @@ +/** + * @fileoverview Pins the resolved values in `constants/layout.ts`. + * + * Most of that file is arithmetic rather than literals, which is what keeps + * the note's proportions honest — but it also means a change to one number + * moves several others, and a well-meant tidy-up of a derivation can move a + * value without looking like it moved anything. These assertions are the + * arithmetic's answer sheet. + * + * Two of them exist for a specific trap. `FOOTER_ICON_SIZE` is derived from + * `TITLE_LINE_HEIGHT` and `FOOTER_FONT_SIZE` deliberately is not, because the + * same three-quarter step that gives an exact 12 gives 11.00025 against the + * body's 14.667px — and that would reach the stylesheet verbatim. The integer + * checks below are what catch a future attempt to make the two symmetrical. + */ + +import {assert} from 'chai'; + +import { + BAR_ICON_MARGIN, + BAR_ICON_SIZE, + BAR_INSET, + BODY_INSET, + DEFAULT_SIZE, + FOOTER_FONT_SIZE, + FOOTER_HANDLE_CLEARANCE, + FOOTER_HEIGHT, + FOOTER_ICON_SIZE, + GLYPH_GRID, + MARKER_ICON_SIZE, + MIN_SIZE, + RESIZE_HANDLE_SIZE, + SCROLLBAR_WIDTH, + TITLE_FONT_SIZE, + TITLE_LINE_HEIGHT, + TOPBAR_HEIGHT, +} from '../src/constants/layout'; + +suite('Layout constants', function () { + test('the note is built on a 16px line box', function () { + assert.equal(TITLE_LINE_HEIGHT, 16); + assert.equal(TITLE_FONT_SIZE, 16); + assert.equal(BAR_ICON_SIZE, 16); + assert.equal(MARKER_ICON_SIZE, 16); + assert.equal(FOOTER_HEIGHT, 16); + }); + + test('the footer is three quarters of the bar', function () { + assert.equal(FOOTER_ICON_SIZE, 12); + assert.equal(FOOTER_FONT_SIZE, 11); + // Both must stay whole: a fraction here reaches the stylesheet as one. + assert.isTrue(Number.isInteger(FOOTER_ICON_SIZE)); + assert.isTrue(Number.isInteger(FOOTER_FONT_SIZE)); + }); + + test('the title bar geometry resolves as drawn', function () { + assert.equal(TOPBAR_HEIGHT, 36); + assert.equal(BAR_ICON_MARGIN, 10); + assert.equal(BAR_INSET, 31); + assert.equal(GLYPH_GRID, 24); + }); + + test('the body and footer resolve as drawn', function () { + assert.equal(BODY_INSET, 10); + assert.equal(RESIZE_HANDLE_SIZE, 12); + assert.equal(FOOTER_HANDLE_CLEARANCE, 22); + assert.equal(SCROLLBAR_WIDTH, 8); + }); + + test('the sizes a note is created and clamped at', function () { + assert.deepEqual(DEFAULT_SIZE, {width: 260, height: 180}); + assert.deepEqual(MIN_SIZE, {width: 84, height: 62}); + }); +}); From 9f7ffa4982530a14f576a16ca074b1ccdd243504 Mon Sep 17 00:00:00 2001 From: Preet Vadaliya Date: Wed, 9 Sep 2026 11:54:23 +0200 Subject: [PATCH 20/20] fix: make the diagrams draw what the code draws They had drifted apart and away from the source: one file gave the title bar a height of 35 and the other 36, the collapsed card was a pixel short, the two disagreed about where the date sits, and the resize handle was drawn eight pixels above the box it occupies. The states diagram drew no resize handle at all, which left the locked note's missing one looking like an omission rather than the point. Both now use the straddling stroke the card really has, carry data-part hooks, and are checked against the constants by a test that ignores colour, wording and artwork so they stay free to redraw. --- .../docs/images/note-anatomy.svg | 131 ++----- .../docs/images/note-states.svg | 188 +++++----- .../test/diagrams.mocha.js | 326 ++++++++++++++++++ 3 files changed, 447 insertions(+), 198 deletions(-) create mode 100644 blockly-workspace-notes/test/diagrams.mocha.js diff --git a/blockly-workspace-notes/docs/images/note-anatomy.svg b/blockly-workspace-notes/docs/images/note-anatomy.svg index b7025e8..f76a181 100644 --- a/blockly-workspace-notes/docs/images/note-anatomy.svg +++ b/blockly-workspace-notes/docs/images/note-anatomy.svg @@ -6,60 +6,25 @@ The parts of a note - - - - - - - - - - - - - - - - - - Shopping list - - - milk - bread - coffee - - - - - - - - - - ada - - - - - - - - 9 Sep 2026 - - - - - + + + + + + Shopping list + milk + bread + coffee + + ada + + 9 Sep 2026 + @@ -100,49 +65,21 @@ What locking changes - - - - - - - - - - - - - - - - Read the brief - - - Written by your teacher. - - - - - - - - - ada - - - - - - - - 9 Sep 2026 + + + + + + Read the brief + Written by your teacher. + + ada + + 9 Sep 2026 diff --git a/blockly-workspace-notes/docs/images/note-states.svg b/blockly-workspace-notes/docs/images/note-states.svg index 111260c..260ee55 100644 --- a/blockly-workspace-notes/docs/images/note-states.svg +++ b/blockly-workspace-notes/docs/images/note-states.svg @@ -6,132 +6,118 @@ Named the title is what you scan for - - - - - - Shopping list + + + + + + Shopping list milk - bread - - ada - - 9 Sep 2026 + bread + + ada + + 9 Sep 2026 + Not named yet faded placeholders, both rows - - - - - - Title + + + + + + Title Say something... - - 9 Sep 2026 + + 9 Sep 2026 + Collapsed the bar is the whole note - - - - - - Release checklist + + + + + + Release checklist Pinned a pin in the bar, and a heavier edge - - - - - - - Do not move + + + + + + + Do not move Held in place. - - ada - - 9 Sep 2026 + + ada + + 9 Sep 2026 + Selected the ring follows the card - - - - - - Ideas + + + + + + Ideas Try a smaller step. - - ada - - 9 Sep 2026 - + + ada + + 9 Sep 2026 + + Locked a padlock in the bar, and no bin - - - - - - Read the brief + + + + + + Read the brief Written by your teacher. - - ada - - 9 Sep 2026 + + ada + + 9 Sep 2026 diff --git a/blockly-workspace-notes/test/diagrams.mocha.js b/blockly-workspace-notes/test/diagrams.mocha.js new file mode 100644 index 0000000..1c3e761 --- /dev/null +++ b/blockly-workspace-notes/test/diagrams.mocha.js @@ -0,0 +1,326 @@ +/** + * @fileoverview Asserts the documentation diagrams still draw what the code + * draws. + * + * They are hand-maintained SVG, and they had drifted: one file gave the title + * bar a height of 35 and the other 36, the collapsed card was a pixel short, + * the two disagreed about where the date sits, and the resize handle was drawn + * eight pixels above the box it actually occupies. None of that is visible by + * eye, and none of it would ever fail a build. + * + * So this reads both files and checks every number a constant can account for. + * + * What it deliberately does **not** check: colours, text content, the callout + * lines and labels, where body lines sit, the canvas size, element order and + * the glyph path data. Those are the parts a person should be free to redraw, + * and a test that pinned them would make the diagrams worse by making them + * expensive to improve. + * + * Elements are addressed by `data-part`, not by position, for the same reason. + */ + +import * as Blockly from 'blockly/core'; +import {assert} from 'chai'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { + BAR_DELETE_NUDGE, + BAR_ICON_MARGIN, + BAR_ICON_SIZE, + BAR_INSET, + BODY_INSET, + CARD_BORDER_WIDTH, + DEFAULT_SIZE, + FOOTER_HEIGHT, + FOOTER_ICON_SIZE, + FOOTER_LABEL_GAP, + FOOTER_FONT_SIZE, + GLYPH_GRID, + GLYPH_STROKE_WIDTH, + LOCK_GLYPH_INK, + MARKER_ICON_GAP, + MARKER_ICON_SIZE, + PINNED_CARD_BORDER_WIDTH, + PIN_GLYPH_INK, + RESIZE_HANDLE_SIZE, + SELECTION_STROKE_WIDTH, + TITLE_FONT_SIZE, + TOPBAR_HEIGHT, +} from '../src/constants/layout'; + +const IMAGES = path.join(process.cwd(), 'docs', 'images'); + +/** Everything rounds to four decimals in the files. */ +const EPSILON = 0.01; + +const BAR_SCALE = MARKER_ICON_SIZE / GLYPH_GRID; +const FOOTER_SCALE = FOOTER_ICON_SIZE / GLYPH_GRID; + +/** + * @param file A file name inside docs/images. + * @returns Its parsed root element. + */ +function readSvg(file) { + const full = path.join(IMAGES, file); + assert.isTrue(fs.existsSync(full), `expected a diagram at ${full}`); + return Blockly.utils.xml.textToDom(fs.readFileSync(full, 'utf8')); +} + +/** + * Reads the translate and uniform scale off a transform attribute. + * + * @param element An element carrying a transform. + * @returns Its translation and scale. + */ +function transformOf(element) { + const transform = element.getAttribute('transform') ?? ''; + const translate = /translate\(\s*(-?[\d.]+)[ ,]+(-?[\d.]+)\s*\)/.exec( + transform, + ); + const scale = /scale\(\s*(-?[\d.]+)/.exec(transform); + assert.isNotNull(translate, `no translate in "${transform}"`); + return { + tx: Number(translate[1]), + ty: Number(translate[2]), + scale: scale ? Number(scale[1]) : 1, + }; +} + +/** + * @param note A note group. + * @param name The `data-part` to find. + * @returns That element, or null when the note omits it. + */ +function part(note, name) { + return note.querySelector(`[data-part="${name}"]`); +} + +/** + * @param element An element. + * @param name An attribute on it. + * @returns That attribute as a number. + */ +function num(element, name) { + return Number(element.getAttribute(name)); +} + +/** + * @param root A parsed diagram. + * @returns Every note group in it, keyed by `data-note`. + */ +function notesIn(root) { + const groups = [...root.querySelectorAll('[data-note]')]; + assert.isAbove(groups.length, 0, 'no note groups found — check data-note'); + return new Map(groups.map((g) => [g.getAttribute('data-note'), g])); +} + +suite('Documentation diagrams', function () { + suiteSetup(function () { + this.diagrams = new Map([ + ['note-anatomy.svg', notesIn(readSvg('note-anatomy.svg'))], + ['note-states.svg', notesIn(readSvg('note-states.svg'))], + ]); + this.every = [...this.diagrams].flatMap(([file, notes]) => + [...notes].map(([kind, note]) => ({file, kind, note})), + ); + // A walk that found nothing would pass every assertion below. + assert.isAtLeast(this.every.length, 8, 'too few notes to be the real set'); + }); + + test('every card is the default size, or the bar when collapsed', function () { + for (const {file, kind, note} of this.every) { + const card = part(note, 'card'); + const where = `${file} ${kind}`; + assert.equal(num(card, 'width'), DEFAULT_SIZE.width, where); + assert.equal( + num(card, 'height'), + kind === 'collapsed' ? TOPBAR_HEIGHT : DEFAULT_SIZE.height, + where, + ); + // Drawn at the origin so the stroke straddles the edge, which is what + // core's highlight rect does. + assert.equal(num(card, 'x') || 0, 0, where); + assert.equal(num(card, 'y') || 0, 0, where); + assert.equal( + num(card, 'stroke-width'), + kind === 'pinned' ? PINNED_CARD_BORDER_WIDTH : CARD_BORDER_WIDTH, + where, + ); + assert.equal(num(part(note, 'bar'), 'height'), TOPBAR_HEIGHT, where); + } + }); + + test('the bar buttons sit where core puts them', function () { + for (const {file, kind, note} of this.every) { + const where = `${file} ${kind}`; + const foldout = transformOf(part(note, 'foldout')); + assert.closeTo(foldout.tx, BAR_ICON_MARGIN, EPSILON, where); + assert.closeTo(foldout.ty, BAR_ICON_MARGIN, EPSILON, where); + assert.closeTo(foldout.scale, BAR_SCALE, EPSILON, where); + + const del = part(note, 'delete'); + if (!del) continue; + const expected = + DEFAULT_SIZE.width - + BAR_ICON_SIZE - + BAR_ICON_MARGIN * 2 + + BAR_DELETE_NUDGE; + assert.closeTo(transformOf(del).tx, expected, EPSILON, where); + } + }); + + test('a marker is placed by its ink, and the title follows it', function () { + for (const {file, kind, note} of this.every) { + const where = `${file} ${kind}`; + const marker = part(note, 'marker'); + const ink = marker + ? kind.includes('locked') + ? LOCK_GLYPH_INK + : PIN_GLYPH_INK + : null; + + if (marker) { + const {tx, ty, scale} = transformOf(marker); + assert.closeTo(tx, BAR_INSET - ink.x * BAR_SCALE, EPSILON, where); + assert.closeTo( + ty, + TOPBAR_HEIGHT / 2 - (ink.y + ink.height / 2) * BAR_SCALE, + EPSILON, + where, + ); + assert.closeTo(scale, BAR_SCALE, EPSILON, where); + } + + const title = part(note, 'title'); + const advance = ink ? ink.width * BAR_SCALE + MARKER_ICON_GAP : 0; + assert.closeTo(num(title, 'x'), BAR_INSET + advance, EPSILON, where); + assert.closeTo(num(title, 'y'), TOPBAR_HEIGHT / 2, EPSILON, where); + assert.equal(num(title, 'font-size'), TITLE_FONT_SIZE, where); + } + }); + + test('the footer sits on its own row', function () { + const textY = DEFAULT_SIZE.height - BODY_INSET - FOOTER_HEIGHT / 2; + const iconY = textY - FOOTER_ICON_SIZE / 2; + for (const {file, kind, note} of this.every) { + const where = `${file} ${kind}`; + const dateIcon = part(note, 'footer-date-icon'); + if (!dateIcon) continue; + + for (const name of ['footer-author-icon', 'footer-date-icon']) { + const icon = part(note, name); + if (!icon) continue; + const {ty, scale} = transformOf(icon); + assert.closeTo(ty, iconY, EPSILON, `${where} ${name}`); + assert.closeTo(scale, FOOTER_SCALE, EPSILON, `${where} ${name}`); + } + + const authorIcon = part(note, 'footer-author-icon'); + if (authorIcon) { + assert.closeTo(transformOf(authorIcon).tx, BODY_INSET, EPSILON, where); + assert.closeTo( + num(part(note, 'footer-author-text'), 'x'), + BODY_INSET + FOOTER_ICON_SIZE + FOOTER_LABEL_GAP, + EPSILON, + where, + ); + } + + // The date's own offset stands in for a measured text width and has no + // constant, so only the gap after its glyph is checked here. + const dateText = part(note, 'footer-date-text'); + assert.closeTo( + num(dateText, 'x') - transformOf(dateIcon).tx, + FOOTER_ICON_SIZE + FOOTER_LABEL_GAP, + EPSILON, + where, + ); + for (const name of ['footer-author-text', 'footer-date-text']) { + const text = part(note, name); + if (!text) continue; + assert.closeTo(num(text, 'y'), textY, EPSILON, `${where} ${name}`); + assert.equal( + num(text, 'font-size'), + FOOTER_FONT_SIZE, + `${where} ${name}`, + ); + } + } + }); + + test('the two files agree on where the date sits', function () { + const offsets = new Set( + this.every + .map( + ({note}) => + part(note, 'footer-author-icon') && part(note, 'footer-date-icon'), + ) + .filter(Boolean) + .map((icon) => transformOf(icon).tx), + ); + assert.equal( + offsets.size, + 1, + `the date glyph stands in for one measured width, but the diagrams ` + + `place it at ${[...offsets].join(' and ')}`, + ); + }); + + test('the resize handle stays inside the box core gives it', function () { + const low = DEFAULT_SIZE.width - BODY_INSET - RESIZE_HANDLE_SIZE; + const lowY = DEFAULT_SIZE.height - BODY_INSET - RESIZE_HANDLE_SIZE; + let seen = 0; + for (const {file, kind, note} of this.every) { + const handle = part(note, 'resize-handle'); + if (!handle) continue; + seen++; + const {tx, ty, scale} = transformOf(handle); + const where = `${file} ${kind}`; + // The artwork is free to change; the box it is drawn in is not. + assert.closeTo(tx, low, EPSILON, where); + assert.closeTo(ty, lowY, EPSILON, where); + assert.closeTo(scale, RESIZE_HANDLE_SIZE / GLYPH_GRID, EPSILON, where); + } + assert.isAtLeast(seen, 1, 'no diagram draws the resize handle'); + }); + + test('a locked note has neither a delete button nor a handle', function () { + const locked = this.every.filter(({kind}) => kind.includes('locked')); + assert.isAtLeast(locked.length, 2, 'both diagrams should show one'); + for (const {file, kind, note} of locked) { + assert.isNull(part(note, 'delete'), `${file} ${kind} draws a bin`); + assert.isNull( + part(note, 'resize-handle'), + `${file} ${kind} draws a resize handle`, + ); + } + }); + + test('the selection ring straddles the card edge', function () { + for (const {file, kind, note} of this.every) { + const ring = part(note, 'selection'); + if (!ring) continue; + const where = `${file} ${kind}`; + assert.equal(num(ring, 'x') || 0, 0, where); + assert.equal(num(ring, 'y') || 0, 0, where); + assert.equal(num(ring, 'width'), DEFAULT_SIZE.width, where); + assert.equal(num(ring, 'height'), DEFAULT_SIZE.height, where); + assert.equal(num(ring, 'stroke-width'), SELECTION_STROKE_WIDTH, where); + } + }); + + test('every glyph is stroked at the authored weight', function () { + for (const {file, kind, note} of this.every) { + const glyphs = [...note.querySelectorAll('g[data-part]')]; + assert.isAbove(glyphs.length, 0, `${file} ${kind} has no glyphs`); + for (const glyph of glyphs) { + assert.equal( + num(glyph, 'stroke-width'), + GLYPH_STROKE_WIDTH, + `${file} ${kind} ${glyph.getAttribute('data-part')}`, + ); + } + } + }); +});