From a879561ec71600e9005ecec838e50295f031e910 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Mon, 20 Jul 2026 01:30:32 +0200 Subject: [PATCH] =?UTF-8?q?#155:=20ChordPro-Plugin=20=E2=80=94=20Akkordbl?= =?UTF-8?q?=C3=A4tter/Leadsheets=20als=20Block?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neues Referenz-Plugin packages/plugins/chordpro nach dem mermaid-Muster: bewusst ohne Fremdbibliothek (Supply-Chain-Lehre aus #136) — eigener minimaler ChordPro-Parser (Direktiven title/subtitle/ artist/key/capo/tempo/comment, Chorus-Fences, [Akkord]-Marker, #-Kommentare) plus SVG-Formatter mit Monospace-Raster: Akkorde über dem Text, Titelkopf, Chorus-Einrückung, XML-escaped. Persistenz {source, svg} — der Snapshot bedient Lesemodus, Public-Ansicht und Exporte über den PluginFallbackRenderer. Edit-Modus: Textarea + debounced Live-Preview. ZIP 20 KB (Limits 64/256 MiB), 4 Parser-/Formatter-Tests, i18n de+en, Doku-Listen (site-admin en+de, plugin-architecture) ergänzt. Prod-Installation wie üblich per Site-Admin. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC --- docs/architecture/plugin-architecture.md | 4 + docs/de/manual/site-admin-guide.md | 7 +- docs/manual/site-admin-guide.md | 9 +- packages/plugins/chordpro/build.mjs | 34 ++++ packages/plugins/chordpro/i18n/de.json | 6 + packages/plugins/chordpro/i18n/en.json | 6 + packages/plugins/chordpro/manifest.json | 18 ++ packages/plugins/chordpro/package.json | 20 ++ .../plugins/chordpro/src/chordpro.test.ts | 59 ++++++ packages/plugins/chordpro/src/chordpro.ts | 191 ++++++++++++++++++ packages/plugins/chordpro/src/plugin.ts | 124 ++++++++++++ packages/plugins/chordpro/tsconfig.json | 11 + pnpm-lock.yaml | 21 ++ 13 files changed, 503 insertions(+), 7 deletions(-) create mode 100644 packages/plugins/chordpro/build.mjs create mode 100644 packages/plugins/chordpro/i18n/de.json create mode 100644 packages/plugins/chordpro/i18n/en.json create mode 100644 packages/plugins/chordpro/manifest.json create mode 100644 packages/plugins/chordpro/package.json create mode 100644 packages/plugins/chordpro/src/chordpro.test.ts create mode 100644 packages/plugins/chordpro/src/chordpro.ts create mode 100644 packages/plugins/chordpro/src/plugin.ts create mode 100644 packages/plugins/chordpro/tsconfig.json diff --git a/docs/architecture/plugin-architecture.md b/docs/architecture/plugin-architecture.md index d084dde..d502886 100644 --- a/docs/architecture/plugin-architecture.md +++ b/docs/architecture/plugin-architecture.md @@ -128,6 +128,10 @@ person looking at it could. build time into `vendor/`, gitignored) and runs fullscreen in the sandbox; blocks store `{ xml, svg }`, render mode and exports use the SVG snapshot. +- `chordpro` (`block`): ChordPro leadsheets — a dependency-free code + block: its own minimal parser renders chords above lyrics into a static + SVG; blocks store `{ source, svg }`, render mode and exports use the + SVG snapshot. - `excalidraw` (`block`): hand-drawn sketches — proves the npm-library flavor of the bundled-app path: the Excalidraw React editor is bundled straight into `plugin.js` (esbuild) with its font/locale assets shipped diff --git a/docs/de/manual/site-admin-guide.md b/docs/de/manual/site-admin-guide.md index e25d682..4323dbd 100644 --- a/docs/de/manual/site-admin-guide.md +++ b/docs/de/manual/site-admin-guide.md @@ -97,7 +97,8 @@ zeigen den Fallback-Text des Plugins, wenn es fehlt. Mitgelieferte Referenz-Plugins: `toc` (Inhaltsverzeichnis), `page-index` (label-gefilterte Seitenliste), `mermaid` (Diagramm-Blöcke), `drawio` (vollwertiges draw.io-Bearbeiten), -`excalidraw` (handgezeichnete Skizzen), `section-styles-basic` +`excalidraw` (handgezeichnete Skizzen), `chordpro` (ChordPro- +Akkordblätter — Akkorde über dem Text), `section-styles-basic` (farbige Hinweiskästen). **Woher kommen die ZIPs der Referenz-Plugins?** Sie liegen den @@ -105,14 +106,14 @@ Server-Images nicht bei — sie werden aus dem Repository gebaut (Node 22 und pnpm, einmalig `pnpm install` im Repo-Wurzelverzeichnis): ```sh -cd packages/plugins/ # toc | page-index | mermaid | drawio | excalidraw | section-styles-basic +cd packages/plugins/ # toc | page-index | mermaid | drawio | excalidraw | chordpro | section-styles-basic pnpm build # → dist/-.zip — diese Datei hochladen ``` Der `drawio`-Build lädt beim ersten Lauf sein gepinntes Editor-Bundle von GitHub und packt ein ~27-MiB-ZIP (innerhalb des 64-MiB-Limits für Plugin-Uploads); `excalidraw` bündelt seinen Editor aus npm in ein -~16-MiB-ZIP; die übrigen vier bauen offline in Sekunden. +~16-MiB-ZIP; die übrigen fünf bauen offline in Sekunden. **Typischer Ablauf:** ZIP hochladen, die abgeschottete Vorschau prüfen, den Instanz-Modus auf `optional` stellen und das Plugin dann je Teich diff --git a/docs/manual/site-admin-guide.md b/docs/manual/site-admin-guide.md index d00100f..00f3e4d 100644 --- a/docs/manual/site-admin-guide.md +++ b/docs/manual/site-admin-guide.md @@ -88,22 +88,23 @@ fallback text when it is missing. Shipped reference plugins: `toc` (table of contents), `page-index` (label-filtered page list), `mermaid` (diagram blocks), `drawio` -(full draw.io editing), `excalidraw` (hand-drawn sketches), -`section-styles-basic` (colored callouts). +(full draw.io editing), `excalidraw` (hand-drawn sketches), `chordpro` +(ChordPro leadsheets — chords above lyrics), `section-styles-basic` +(colored callouts). **Getting the reference plugin ZIPs.** They are not bundled with the server images — build them from the repository (Node 22 + pnpm, one-time `pnpm install` at the repo root): ```sh -cd packages/plugins/ # toc | page-index | mermaid | drawio | excalidraw | section-styles-basic +cd packages/plugins/ # toc | page-index | mermaid | drawio | excalidraw | chordpro | section-styles-basic pnpm build # → dist/-.zip — upload that file ``` The `drawio` build downloads its pinned editor bundle from GitHub on the first run and packs a ~27 MiB ZIP (within the 64 MiB plugin upload limit); `excalidraw` bundles its editor from npm into a ~16 MiB ZIP; the -remaining four build offline in seconds. +remaining five build offline in seconds. **Typical rollout:** upload the ZIP, check the sandboxed preview, switch the instance mode to `optional`, then enable the plugin per pond (pond diff --git a/packages/plugins/chordpro/build.mjs b/packages/plugins/chordpro/build.mjs new file mode 100644 index 0000000..113a4a0 --- /dev/null +++ b/packages/plugins/chordpro/build.mjs @@ -0,0 +1,34 @@ +// Builds the installable plugin (plugin-architecture.md §Package format): +// bundles src/plugin.ts (SDK + i18n inlined — the sandbox CSP forbids runtime +// fetches) into plugin.js as a single ES module, then packs the ZIP for the +// admin upload / dropzone watcher. +import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { build } from 'esbuild'; +import { zipSync } from 'fflate'; + +const root = dirname(fileURLToPath(import.meta.url)); +const manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8')); + +mkdirSync(join(root, 'dist'), { recursive: true }); +await build({ + entryPoints: [join(root, 'src/plugin.ts')], + bundle: true, + format: 'esm', + outfile: join(root, 'dist/plugin.js'), + minify: true, +}); + +const files = { + 'manifest.json': readFileSync(join(root, 'manifest.json')), + 'plugin.js': readFileSync(join(root, 'dist/plugin.js')), +}; +for (const name of readdirSync(join(root, 'i18n'))) { + files[`i18n/${name}`] = readFileSync(join(root, 'i18n', name)); +} + +const target = join(root, 'dist', `${manifest.id}-${manifest.version}.zip`); +writeFileSync(target, zipSync(files)); +console.log(`wrote ${target}`); diff --git a/packages/plugins/chordpro/i18n/de.json b/packages/plugins/chordpro/i18n/de.json new file mode 100644 index 0000000..ceeb64b --- /dev/null +++ b/packages/plugins/chordpro/i18n/de.json @@ -0,0 +1,6 @@ +{ + "placeholder": "ChordPro-Quelle, z. B.: {title: Mein Lied}\n[C]Hallo [G]Welt", + "empty": "Noch kein Akkordblatt — gib im Bearbeiten-Modus ChordPro-Quelltext ein.", + "error": "Das Blatt ist leer oder konnte nicht gerendert werden.", + "stale": "Zeigt den letzten gültigen Stand; die aktuelle Quelle ist leer." +} diff --git a/packages/plugins/chordpro/i18n/en.json b/packages/plugins/chordpro/i18n/en.json new file mode 100644 index 0000000..4864947 --- /dev/null +++ b/packages/plugins/chordpro/i18n/en.json @@ -0,0 +1,6 @@ +{ + "placeholder": "ChordPro source, e.g.: {title: My Song}\n[C]Hello [G]world", + "empty": "No chord sheet yet — enter ChordPro source in edit mode.", + "error": "The sheet is empty or could not be rendered.", + "stale": "Showing the last valid state; the current source is empty." +} diff --git a/packages/plugins/chordpro/manifest.json b/packages/plugins/chordpro/manifest.json new file mode 100644 index 0000000..081b38c --- /dev/null +++ b/packages/plugins/chordpro/manifest.json @@ -0,0 +1,18 @@ +{ + "id": "chordpro", + "name": "ChordPro Leadsheets", + "version": "1.0.0", + "apiVersion": "1", + "kind": "code", + "extensionPoints": [ + { + "type": "block", + "id": "sheet", + "title": { "de": "Akkordblatt (ChordPro)", "en": "Chord sheet (ChordPro)" } + } + ], + "permissions": ["blockData", "ui"], + "fallback": { "type": "text", "value": "[Chord sheet]" }, + "license": "MIT", + "i18n": { "de": "i18n/de.json", "en": "i18n/en.json" } +} diff --git a/packages/plugins/chordpro/package.json b/packages/plugins/chordpro/package.json new file mode 100644 index 0000000..cb7e1a9 --- /dev/null +++ b/packages/plugins/chordpro/package.json @@ -0,0 +1,20 @@ +{ + "name": "@dorfteich/plugin-chordpro", + "version": "0.0.0", + "private": true, + "description": "Block plugin: ChordPro leadsheets — chords above lyrics, rendered to a static SVG (issue #155)", + "license": "MIT", + "scripts": { + "build": "node build.mjs", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "devDependencies": { + "@dorfteich/plugin-sdk": "workspace:*", + "@types/node": "^26.1.0", + "esbuild": "^0.24.0", + "fflate": "^0.8.2", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} diff --git a/packages/plugins/chordpro/src/chordpro.test.ts b/packages/plugins/chordpro/src/chordpro.test.ts new file mode 100644 index 0000000..1e5813d --- /dev/null +++ b/packages/plugins/chordpro/src/chordpro.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { chordProToSvg, parseChordPro } from './chordpro'; + +const SHEET = `{title: Bühnenlied} +{artist: Nadia Morgenstern} +{key: C} +# internes Kommentar — unsichtbar + +[C]Hallo du [G]schöne [Am]Welt +Ohne Akkorde geht es [F]auch + +{start_of_chorus} +{comment: Refrain} +[C]La la [G]la +{end_of_chorus}`; + +describe('chordpro parser + svg formatter (issue #155)', () => { + it('parses directives, chords with columns, comments, and chorus', () => { + const sheet = parseChordPro(SHEET); + expect(sheet.meta).toMatchObject({ + title: 'Bühnenlied', + subtitle: 'Nadia Morgenstern', + key: 'C', + }); + const first = sheet.lines[0]!; + expect(first.lyrics).toBe('Hallo du schöne Welt'); + expect(first.chords).toEqual([ + { at: 0, chord: 'C' }, + { at: 9, chord: 'G' }, + { at: 16, chord: 'Am' }, + ]); + const chorusLine = sheet.lines.find((l) => l.chorus && l.kind === 'pair')!; + expect(chorusLine.lyrics).toBe('La la la'); + const comment = sheet.lines.find((l) => l.kind === 'comment')!; + expect(comment.lyrics).toBe('Refrain'); + // `#` lines never show up. + expect(sheet.lines.some((l) => l.lyrics.includes('internes'))).toBe(false); + }); + + it('renders a self-contained SVG with title, chords, and umlauts escaped safely', () => { + const svg = chordProToSvg(SHEET); + expect(svg).toMatch(/^Am'); + expect(svg).toContain('Hallo du schöne Welt'); + expect(svg).not.toContain(' { + const svg = chordProToSvg('{title: }\n[C]a & b < c'); + expect(svg).toContain('<img src=x>'); + expect(svg).toContain('a & b < c'); + }); + + it('throws on an empty sheet so the plugin can show its hint', () => { + expect(() => chordProToSvg(' \n# nur Kommentar')).toThrow(); + }); +}); diff --git a/packages/plugins/chordpro/src/chordpro.ts b/packages/plugins/chordpro/src/chordpro.ts new file mode 100644 index 0000000..0e873e3 --- /dev/null +++ b/packages/plugins/chordpro/src/chordpro.ts @@ -0,0 +1,191 @@ +/** + * Minimal ChordPro parser + SVG leadsheet formatter (issue #155). + * + * Deliberately hand-rolled instead of pulling in a chord-sheet library: the + * subset a leadsheet needs is small (directives, `[chord]` markers, comments, + * chorus fences), and the SVG layout would be custom code either way — this + * keeps the plugin dependency-free (supply-chain lesson from #136). + * + * Supported ChordPro: + * - `{title: …}` / `{t: …}`, `{subtitle: …}`/`{st: …}`/`{artist: …}`, + * `{key: …}`, `{capo: …}`, `{tempo: …}` + * - `{comment: …}` / `{c: …}` — shown as an italic line + * - `{start_of_chorus}`/`{soc}` … `{end_of_chorus}`/`{eoc}` — indented block + * - lyric lines with inline `[C]`/`[Am7]`/… chord markers + * - `#` comment lines (ignored), blank lines (verse separation) + */ + +export interface SheetMeta { + title: string; + subtitle: string; + key: string; + capo: string; + tempo: string; +} + +export interface SheetLine { + kind: 'pair' | 'comment' | 'blank'; + /** Lyric text with the chord markers removed. */ + lyrics: string; + /** Chords with the lyric column (character index) they sit above. */ + chords: { at: number; chord: string }[]; + chorus: boolean; +} + +export interface Sheet { + meta: SheetMeta; + lines: SheetLine[]; +} + +const DIRECTIVE = /^\{\s*([a-z_]+)\s*(?::\s*(.*?))?\s*\}\s*$/i; +const CHORD = /\[([^\][\n]+)\]/g; + +export function parseChordPro(source: string): Sheet { + const meta: SheetMeta = { title: '', subtitle: '', key: '', capo: '', tempo: '' }; + const lines: SheetLine[] = []; + let chorus = false; + + for (const raw of source.split('\n')) { + const line = raw.replace(/\s+$/, ''); + if (line.trimStart().startsWith('#')) continue; + + const directive = DIRECTIVE.exec(line.trim()); + if (directive) { + const name = directive[1]!.toLowerCase(); + const value = (directive[2] ?? '').trim(); + if (name === 'title' || name === 't') meta.title = value; + else if (name === 'subtitle' || name === 'st' || name === 'artist') meta.subtitle = value; + else if (name === 'key') meta.key = value; + else if (name === 'capo') meta.capo = value; + else if (name === 'tempo') meta.tempo = value; + else if (name === 'start_of_chorus' || name === 'soc') chorus = true; + else if (name === 'end_of_chorus' || name === 'eoc') chorus = false; + else if (name === 'comment' || name === 'c') { + lines.push({ kind: 'comment', lyrics: value, chords: [], chorus }); + } + // Unknown directives are ignored on purpose (forward compatibility). + continue; + } + + if (line.trim() === '') { + lines.push({ kind: 'blank', lyrics: '', chords: [], chorus }); + continue; + } + + let lyrics = ''; + const chords: { at: number; chord: string }[] = []; + let last = 0; + for (const match of line.matchAll(CHORD)) { + lyrics += line.slice(last, match.index); + chords.push({ at: lyrics.length, chord: match[1]! }); + last = match.index! + match[0].length; + } + lyrics += line.slice(last); + lines.push({ kind: 'pair', lyrics, chords, chorus }); + } + + // Trim leading/trailing blank lines. + while (lines[0]?.kind === 'blank') lines.shift(); + while (lines[lines.length - 1]?.kind === 'blank') lines.pop(); + return { meta, lines }; +} + +/** Monospace metrics — fixed so the static SVG lays out identically wherever + * it renders (editor sandbox, public view, PDF export). */ +const CHAR_W = 8.4; +const LINE_H = 18; +const PAD = 12; +const CHORUS_INDENT = 2; + +function escapeXml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +/** The sheet as a self-contained SVG: chords above lyrics, monospace grid. */ +export function sheetToSvg(sheet: Sheet): string { + const parts: string[] = []; + let y = PAD + 4; + let maxCols = 0; + + const push = (text: string, x: number, options: string): void => { + parts.push(`${escapeXml(text)}`); + }; + + if (sheet.meta.title) { + y += 20; + push(sheet.meta.title, PAD, 'font-size="18" font-weight="bold" font-family="sans-serif"'); + maxCols = Math.max(maxCols, (sheet.meta.title.length * 11) / CHAR_W); + } + const subtitleBits = [ + sheet.meta.subtitle, + sheet.meta.key && `Key: ${sheet.meta.key}`, + sheet.meta.capo && `Capo: ${sheet.meta.capo}`, + sheet.meta.tempo && `♩ ${sheet.meta.tempo}`, + ].filter(Boolean) as string[]; + if (subtitleBits.length > 0) { + y += LINE_H; + push(subtitleBits.join(' — '), PAD, 'font-size="13" fill="#555" font-family="sans-serif"'); + maxCols = Math.max(maxCols, subtitleBits.join(' — ').length); + } + if (sheet.meta.title || subtitleBits.length > 0) y += 8; + + for (const line of sheet.lines) { + const indent = line.chorus ? CHORUS_INDENT : 0; + const x = PAD + indent * CHAR_W; + if (line.kind === 'blank') { + y += LINE_H * 0.7; + continue; + } + if (line.kind === 'comment') { + y += LINE_H; + push( + line.lyrics, + x, + 'font-size="13" font-style="italic" fill="#555" font-family="sans-serif"', + ); + maxCols = Math.max(maxCols, indent + line.lyrics.length); + continue; + } + if (line.chords.length > 0) { + y += LINE_H; + for (const { at, chord } of line.chords) { + parts.push( + `` + + `${escapeXml(chord)}`, + ); + maxCols = Math.max(maxCols, indent + at + chord.length); + } + } + y += LINE_H; + push( + line.lyrics, + x, + 'font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" xml:space="preserve"', + ); + maxCols = Math.max(maxCols, indent + line.lyrics.length); + } + + const width = Math.max(240, Math.ceil(PAD * 2 + maxCols * CHAR_W)); + const height = y + PAD; + return ( + `` + + parts.join('') + + `` + ); +} + +/** Parse + render in one step; throws on an empty sheet so callers can show + * their empty-state hint instead of a blank box. */ +export function chordProToSvg(source: string): string { + const sheet = parseChordPro(source); + if (sheet.lines.length === 0 && !sheet.meta.title) { + throw new Error('empty sheet'); + } + return sheetToSvg(sheet); +} diff --git a/packages/plugins/chordpro/src/plugin.ts b/packages/plugins/chordpro/src/plugin.ts new file mode 100644 index 0000000..56c2996 --- /dev/null +++ b/packages/plugins/chordpro/src/plugin.ts @@ -0,0 +1,124 @@ +import { createPlugin, windowTransport, type RenderContext } from '@dorfteich/plugin-sdk'; + +import de from '../i18n/de.json'; +import en from '../i18n/en.json'; +import { chordProToSvg } from './chordpro'; + +/** + * ChordPro leadsheet plugin (issue #155), following the mermaid reference + * plugin's shape: the source text is the document of record, `svg` is the + * last successfully rendered snapshot — persisted together so the read view, + * public view, and office/PDF exports show the sheet without executing + * anything (#79). Edit mode is a source textarea with a live, debounced + * preview; render mode shows the rendered sheet. + */ +const STRINGS: Record> = { de, en }; +const PREVIEW_DEBOUNCE_MS = 300; + +function labelFor(locale: string, key: string): string { + const base = locale.split('-')[0] ?? locale; + return STRINGS[base]?.[key] ?? STRINGS.en?.[key] ?? key; +} + +interface SheetData { + source?: string; + svg?: string; +} + +function dataOf(context: RenderContext): SheetData { + return context.data && typeof context.data === 'object' ? (context.data as SheetData) : {}; +} + +const { host } = createPlugin({ + transport: windowTransport({ + target: { postMessage: (message) => window.parent.postMessage(message, '*') }, + source: window, + }), + onRender: (context) => renderMode(context), + onEdit: (context) => editMode(context), +}); + +function resize(): void { + void host.ui.resize(Math.max(64, document.body.scrollHeight + 16)); +} + +function renderMode(context: RenderContext): void { + const data = dataOf(context); + document.body.textContent = ''; + document.body.className = 'dt-chordpro dt-chordpro--render'; + + const source = (data.source ?? '').trim(); + if (source === '') { + const hint = document.createElement('p'); + hint.textContent = labelFor(context.locale, 'empty'); + document.body.appendChild(hint); + resize(); + return; + } + + try { + document.body.innerHTML = chordProToSvg(source); + } catch { + if (data.svg) { + document.body.innerHTML = data.svg; + const note = document.createElement('p'); + note.textContent = labelFor(context.locale, 'stale'); + document.body.appendChild(note); + } else { + document.body.textContent = labelFor(context.locale, 'error'); + } + } + resize(); +} + +function editMode(context: RenderContext): void { + const data = dataOf(context); + document.body.textContent = ''; + document.body.className = 'dt-chordpro dt-chordpro--edit'; + + const textarea = document.createElement('textarea'); + textarea.placeholder = labelFor(context.locale, 'placeholder'); + textarea.value = data.source ?? ''; + textarea.rows = 10; + textarea.style.width = '100%'; + textarea.style.boxSizing = 'border-box'; + textarea.style.fontFamily = 'monospace'; + + const error = document.createElement('p'); + error.className = 'dt-chordpro__error'; + error.style.color = '#b91c1c'; + error.hidden = true; + + const preview = document.createElement('div'); + preview.className = 'dt-chordpro__preview'; + if (data.svg) preview.innerHTML = data.svg; + + document.body.append(textarea, error, preview); + resize(); + + let debounce: number | undefined; + let lastGoodSvg = data.svg ?? ''; + const refresh = (): void => { + const source = textarea.value; + try { + const svg = chordProToSvg(source); + lastGoodSvg = svg; + preview.innerHTML = svg; + error.hidden = true; + // Persist source + snapshot together — the snapshot carries the read + // view, public view, and exports (#79). + void host.blockData.setData({ source, svg }); + } catch { + error.textContent = labelFor(context.locale, 'error'); + error.hidden = false; + void host.blockData.setData({ source, svg: lastGoodSvg }); + } + resize(); + }; + + textarea.addEventListener('input', () => { + window.clearTimeout(debounce); + debounce = window.setTimeout(refresh, PREVIEW_DEBOUNCE_MS); + }); + if (textarea.value.trim() !== '' && !data.svg) refresh(); +} diff --git a/packages/plugins/chordpro/tsconfig.json b/packages/plugins/chordpro/tsconfig.json new file mode 100644 index 0000000..1dbe32b --- /dev/null +++ b/packages/plugins/chordpro/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "noEmit": true, + "lib": ["ES2022", "DOM"] + }, + "include": ["src", "*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d04f0ab..7a7c7e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -392,6 +392,27 @@ importers: specifier: ^3.0.0 version: 3.2.6(@types/node@26.1.0)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0)(tsx@4.23.0) + packages/plugins/chordpro: + devDependencies: + '@dorfteich/plugin-sdk': + specifier: workspace:* + version: link:../../plugin-sdk + '@types/node': + specifier: ^26.1.0 + version: 26.1.0 + esbuild: + specifier: ^0.24.0 + version: 0.24.2 + fflate: + specifier: ^0.8.2 + version: 0.8.3 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.6(@types/node@26.1.0)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0)(tsx@4.23.0) + packages/plugins/drawio: devDependencies: '@dorfteich/plugin-sdk': -- 2.45.2