M23: ChordPro-Plugin — Akkordblätter/Leadsheets (#155) #159
@ -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
|
||||
|
||||
@ -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/<name> # toc | page-index | mermaid | drawio | excalidraw | section-styles-basic
|
||||
cd packages/plugins/<name> # toc | page-index | mermaid | drawio | excalidraw | chordpro | section-styles-basic
|
||||
pnpm build # → dist/<id>-<version>.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
|
||||
|
||||
@ -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/<name> # toc | page-index | mermaid | drawio | excalidraw | section-styles-basic
|
||||
cd packages/plugins/<name> # toc | page-index | mermaid | drawio | excalidraw | chordpro | section-styles-basic
|
||||
pnpm build # → dist/<id>-<version>.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
|
||||
|
||||
34
packages/plugins/chordpro/build.mjs
Normal file
34
packages/plugins/chordpro/build.mjs
Normal file
@ -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}`);
|
||||
6
packages/plugins/chordpro/i18n/de.json
Normal file
6
packages/plugins/chordpro/i18n/de.json
Normal file
@ -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."
|
||||
}
|
||||
6
packages/plugins/chordpro/i18n/en.json
Normal file
6
packages/plugins/chordpro/i18n/en.json
Normal file
@ -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."
|
||||
}
|
||||
18
packages/plugins/chordpro/manifest.json
Normal file
18
packages/plugins/chordpro/manifest.json
Normal file
@ -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" }
|
||||
}
|
||||
20
packages/plugins/chordpro/package.json
Normal file
20
packages/plugins/chordpro/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
59
packages/plugins/chordpro/src/chordpro.test.ts
Normal file
59
packages/plugins/chordpro/src/chordpro.test.ts
Normal file
@ -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(/^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg"/);
|
||||
expect(svg).toContain('Bühnenlied');
|
||||
expect(svg).toContain('>Am</text>');
|
||||
expect(svg).toContain('Hallo du schöne Welt');
|
||||
expect(svg).not.toContain('<script');
|
||||
});
|
||||
|
||||
it('escapes XML-hostile input', () => {
|
||||
const svg = chordProToSvg('{title: <img src=x>}\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();
|
||||
});
|
||||
});
|
||||
191
packages/plugins/chordpro/src/chordpro.ts
Normal file
191
packages/plugins/chordpro/src/chordpro.ts
Normal file
@ -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(`<text x="${x}" y="${y}" ${options}>${escapeXml(text)}</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(
|
||||
`<text x="${PAD + (indent + at) * CHAR_W}" y="${y}" font-size="13" font-weight="bold"` +
|
||||
` fill="#1d4ed8" font-family="ui-monospace, SFMono-Regular, Menlo, monospace">` +
|
||||
`${escapeXml(chord)}</text>`,
|
||||
);
|
||||
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 (
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}"` +
|
||||
` width="${width}" height="${height}" role="img">` +
|
||||
parts.join('') +
|
||||
`</svg>`
|
||||
);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
124
packages/plugins/chordpro/src/plugin.ts
Normal file
124
packages/plugins/chordpro/src/plugin.ts
Normal file
@ -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<string, Record<string, string>> = { 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();
|
||||
}
|
||||
11
packages/plugins/chordpro/tsconfig.json
Normal file
11
packages/plugins/chordpro/tsconfig.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true,
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["src", "*.ts"]
|
||||
}
|
||||
21
pnpm-lock.yaml
generated
21
pnpm-lock.yaml
generated
@ -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':
|
||||
|
||||
Loading…
Reference in New Issue
Block a user