From 267c693c871e3a92776305165d3cfd67356d3bdc Mon Sep 17 00:00:00 2001 From: Anas Rashid Date: Fri, 9 Oct 2026 00:53:17 +0200 Subject: [PATCH] Urdu content editor on Tiptap (#28): structured editing of Divan text, with the source view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - web/src/lib/editor.ts (Tiptap 3 MIT core): verse units of misras (1 = مصرع, 2 = شعر, 3+ = بند, as in Divan text) with مطلع/حسن مطلع/مقطع labels, prose paragraphs, chapter headings and sub-headings, a dictionary-link mark, footnotes and variant readings; Enter adds a misra (or moves into the next empty one), Enter on an empty misra starts a new unit. - Revision page: editor and source tabs over the same text, the work's data points as rows, a toolbar (couplet, paragraph, headings, labels, dictionary link, footnote, variant, undo/redo, Roman typing, Urdu keyboard). Saving always sends Divan text. - Divan text: inline segments keep where links, footnotes and variants sit; toText writes a document back. All 11,087 works round-trip unchanged through toText and through the editor. - The Urdu keyboard and Roman typing type into rich editors; the reader's word sidebar and selection menu stay out of editors. Co-Authored-By: Claude Opus 5.5 --- api/src/divantext.test.ts | 13 ++ api/src/divantext.ts | 70 +++++++-- web/package-lock.json | 240 +++++++++++++++++++++++++++++++ web/package.json | 5 + web/src/layouts/Base.astro | 7 +- web/src/lib/editor.ts | 163 +++++++++++++++++++++ web/src/pages/mod/rev/[id].astro | 86 ++++++++++- web/src/styles/global.css | 28 ++++ 8 files changed, 591 insertions(+), 21 deletions(-) create mode 100644 web/src/lib/editor.ts diff --git a/api/src/divantext.test.ts b/api/src/divantext.test.ts index 9e7bed4e..ce564381 100644 --- a/api/src/divantext.test.ts +++ b/api/src/divantext.test.ts @@ -62,3 +62,16 @@ test('TEI export is well-formed XML with couplets as of two if (!lint.error) assert.equal(lint.status, 0, String(lint.stderr)); if (!lint.error) assert.equal(spawnSync('xmllint', ['--noout', '-'], { input: toTEI(prose) }).status, 0); }); + +test('writing back: parse(toText(doc)) is the same doc; segments keep links, notes and variants in place', async () => { + const { toText, segmentsText } = await import('./divantext.ts'); + for (const d of [ghazal, prose, parse('\n{{مقطع}} [[لغت:دیوانہ|دیوانے]] ہیںحاشیہ {{نسخہ|الف|ب|ماخذ=س}}\nدوسرا\n')]) { + const again = parse(toText(d)); + assert.deepEqual(again.meta, d.meta); + assert.deepEqual(again.blocks, d.blocks); + } + const l = inline('کوئی [[لغت:دیوانہ|دیوانے]] ہیںنوٹ اور {{نسخہ|الف|ب|ماخذ=س}}'); + assert.deepEqual(l.segments, [{ text: 'کوئی ' }, { lemma: 'دیوانہ', text: 'دیوانے' }, { text: ' ہیں' }, { note: 'نوٹ' }, { text: ' اور ' }, + { variant: { shown: 'الف', others: ['ب'], source: 'س' } }]); + assert.equal(segmentsText(l.segments), 'کوئی [[لغت:دیوانہ|دیوانے]] ہیںنوٹ اور {{نسخہ|الف|ب|ماخذ=س}}'); +}); diff --git a/api/src/divantext.ts b/api/src/divantext.ts index 1051eee6..fea6213c 100644 --- a/api/src/divantext.ts +++ b/api/src/divantext.ts @@ -15,7 +15,10 @@ // Words: split on spaces; a zero-width non-joiner keeps a compound one word (بے‌ثبوت); punctuation ، ۔ ؟ ! is // not part of a word; the izafat kasra stays on its word. -export type Inline = { text: string; words: { shown: string; lemma: string }[]; notes: string[]; variants: { shown: string; others: string[]; source?: string }[] }; +export type Variant = { shown: string; others: string[]; source?: string }; +// a line in order: plain text, a word linked to the dictionary, a footnote, a variant reading (for editors) +export type Segment = { text: string } | { text: string; lemma: string } | { note: string } | { variant: Variant }; +export type Inline = { text: string; words: { shown: string; lemma: string }[]; notes: string[]; variants: Variant[]; segments: Segment[] }; export type Block = | { type: 'heading'; text: string; level: number } // == chapter == is level 2, === sub-heading === level 3, … | { type: 'para'; line: Inline } @@ -44,21 +47,58 @@ function template(src: string) { } // one line of text: links, notes and variants out, plain text in +const TOKEN = /([\s\S]*?)<\/ref>|(\{\{نسخہ\|[^{}]*\}\})|\[\[(?:لغت:)?([^\]|]+)(?:\|([^\]]+))?\]\]/g; + export function inline(src: string): Inline { - const words: Inline['words'] = [], notes: string[] = [], variants: Inline['variants'] = []; - let text = src - .replace(/([\s\S]*?)<\/ref>/g, (_, n) => { notes.push(n.trim()); return ''; }) - .replace(/\{\{نسخہ\|[^{}]*\}\}/g, (t) => { - const { fields } = template(t), others = Object.keys(fields).filter((k) => /^\d+$/.test(k) && k !== '1').map((k) => fields[k]); - variants.push({ shown: fields['1'] ?? '', others, source: fields['ماخذ'] }); - return fields['1'] ?? ''; - }) - .replace(/\[\[(?:لغت:)?([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_, lemma, shown) => { - words.push({ lemma: lemma.trim(), shown: (shown ?? lemma).trim() }); - return (shown ?? lemma).trim(); - }); - text = text.replace(/[ \t]+/g, ' ').trim(); - return { text, words, notes, variants }; + const segments: Segment[] = []; + let at = 0; + const plain = (t: string) => { if (t) segments.push({ text: t.replace(/[ \t]+/g, ' ') }); }; + for (const m of src.matchAll(TOKEN)) { + plain(src.slice(at, m.index)); + at = m.index! + m[0].length; + if (m[1] !== undefined) segments.push({ note: m[1].trim() }); + else if (m[2]) { + const { fields } = template(m[2]); + segments.push({ variant: { shown: fields['1'] ?? '', others: Object.keys(fields).filter((k) => /^\d+$/.test(k) && k !== '1').map((k) => fields[k]), + ...(fields['ماخذ'] && { source: fields['ماخذ'] }) } }); + } else segments.push({ lemma: m[3].trim(), text: (m[4] ?? m[3]).trim() }); + } + plain(src.slice(at)); + // trim the line's outer spaces (in its first and last text) + const texts = segments.filter((x): x is { text: string } => 'text' in x && !('lemma' in x)); + if (texts[0] && segments[0] === texts[0]) texts[0].text = texts[0].text.trimStart(); + if (texts.at(-1) && segments.at(-1) === texts.at(-1)) texts.at(-1)!.text = texts.at(-1)!.text.trimEnd(); + const shown = segments.map((x) => ('text' in x ? x.text : 'variant' in x ? x.variant.shown : '')).join(''); + return { + text: shown.replace(/[ \t]+/g, ' ').trim(), + words: segments.filter((x): x is { text: string; lemma: string } => 'lemma' in x).map((x) => ({ shown: x.text, lemma: x.lemma })), + notes: segments.filter((x): x is { note: string } => 'note' in x).map((x) => x.note), + variants: segments.filter((x): x is { variant: Variant } => 'variant' in x).map((x) => x.variant), + segments: segments.filter((x) => !('text' in x && !('lemma' in x) && x.text === '')), + }; +} + +// ---- writing Divan text back (editors build a Doc; this is its text) ---- + +export const segmentsText = (segs: Segment[]) => segs.map((x) => + 'note' in x ? `${x.note}` + : 'variant' in x ? `{{نسخہ|${[x.variant.shown, ...x.variant.others].join('|')}${x.variant.source ? `|ماخذ=${x.variant.source}` : ''}}}` + : 'lemma' in x ? (x.lemma === x.text ? `[[${x.text}]]` : `[[لغت:${x.lemma}|${x.text}]]`) + : x.text).join(''); + +export function toText(doc: Doc) { + const out: string[] = []; + const meta = Object.entries(doc.meta).filter(([, v]) => v !== undefined); + if (meta.length) out.push(`{{دیوان\n${meta.map(([k, v]) => `| ${k} = ${v}`).join('\n')}\n}}`); + let verse: string[] = []; + const flush = () => { if (verse.length) out.push(`\n${verse.join('\n\n')}\n`); verse = []; }; + for (const b of doc.blocks) { + if (b.type === 'heading') { flush(); const eq = '='.repeat(b.level); out.push(`${eq} ${b.text} ${eq}`); } + else if (b.type === 'para') { flush(); out.push(segmentsText(b.line.segments)); } + else verse.push(b.lines.map((l, i) => (i === 0 && b.label ? `{{${b.label}}} ` : '') + segmentsText(l.segments)).join('\n')); + } + flush(); + return out.join('\n\n') + '\n'; } export function parse(src: string): Doc { diff --git a/web/package-lock.json b/web/package-lock.json index ff24cf68..5381f918 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,6 +9,11 @@ "@astrojs/node": "^11.1.7", "@fontsource/noto-naskh-arabic": "^5.3.0", "@fontsource/noto-nastaliq-urdu": "^5.3.0", + "@tiptap/core": "^3.31.4", + "@tiptap/extension-document": "^3.31.4", + "@tiptap/extension-text": "^3.31.4", + "@tiptap/extensions": "^3.31.4", + "@tiptap/pm": "^3.31.4", "astro": "^7.3.8" } }, @@ -1543,6 +1548,84 @@ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "license": "MIT" }, + "node_modules/@tiptap/core": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.31.4.tgz", + "integrity": "sha512-cIVUQYBycighzsk6GbmCef8ZiEDVxoT5EuAe4xg2q9dNkDqCXVqI4YGr9aavCQEiAS4uHiRwqvcsPqJHipFDfg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "3.31.4" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.31.4.tgz", + "integrity": "sha512-vT20u62RWR+BLIyNJ54e24MPUY4zm8VF7tcgJnxQXi7BgVJWcNMtMWWDIkPJ4JNXwfoIznKh3jzYsaYmbCJgQQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.4.tgz", + "integrity": "sha512-WL2ZRpFksgQcXJN05/UQH09YCH9LlhdLrSEcviASvEH/aAfsKiJlDrouN8PET+YAWK/F5xFc5/Q1z0xj+I23jw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.31.4.tgz", + "integrity": "sha512-nIupIne/sHpV2Kw1M4MOmr4+G454KI8BXM5+LWKOU2EpzfALZWVSy7koF9lVRna4Zp5Gv8hfg5Z8kvmrYQvhkw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.4", + "@tiptap/pm": "3.31.4" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.31.4", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.31.4.tgz", + "integrity": "sha512-x7dwth79cQp4QGae66/RJqMDJQv0+n7b7dD7cyFQBWqzPFLJxN/4paHY4qbWedZjNLhRyIOAD9a5q4AYM1zmPA==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.4.1", + "prosemirror-commands": "^1.7.1", + "prosemirror-dropcursor": "^1.8.2", + "prosemirror-gapcursor": "^1.4.1", + "prosemirror-history": "^1.5.0", + "prosemirror-inputrules": "^1.5.1", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.11", + "prosemirror-schema-list": "^1.5.1", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.5", + "prosemirror-transform": "^1.12.0", + "prosemirror-view": "^1.42.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.4", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", @@ -3013,6 +3096,12 @@ "regex-recursion": "^6.0.2" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/p-limit": { "version": "7.3.3", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.3.tgz", @@ -3142,6 +3231,145 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/prosemirror-changeset": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.4.tgz", + "integrity": "sha512-Gyuz9jYdrAeA9I7AholyOes9KUmv/lEwsC5O9s5xF5VB3EgTPsXMGUxz4+H1B+wKVPdZbvRcg1Cp3x8qX+8ofA==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz", + "integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.4.tgz", + "integrity": "sha512-VmKxhcA6a+h5oPSYmfF1ZNeQqPbyYaIxqZsKqeYCyTlPtrjUgNaHL3P8vVaO19oqAV0zuaM/axkowZHrB1P3Tg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.1.tgz", + "integrity": "sha512-onlwnqKvgtFg6srZOmDOAhlAy1FIHQpOExORybX0S+DvJepE5tPALrB9iGet/2sqqqrelSnB10OoiOJqAvu2DA==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.12", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.12.tgz", + "integrity": "sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.2.tgz", + "integrity": "sha512-PE/aY0HEY4zczvmqrilgkUK/WautF0chvMqkmM/iN5/aPHvwrCWaQZjS6CIZp+K84YrvPFqrL+YArrfJX8xX7g==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.42.6", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.6.tgz", + "integrity": "sha512-3g25f6hKV2bBxo3puH3EcYWmmVNSH0iWmNsMVT7sugDSpA025jgpOLarcDWr39SdzPjYMaiYtRdNtXialPSjEQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.12", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/radix3": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", @@ -3255,6 +3483,12 @@ "@rolldown/binding-win32-x64-msvc": "1.2.13" } }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, "node_modules/satteri": { "version": "0.10.5", "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz", @@ -3925,6 +4159,12 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/xxhash-wasm": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", diff --git a/web/package.json b/web/package.json index dcd77516..16f36874 100644 --- a/web/package.json +++ b/web/package.json @@ -12,6 +12,11 @@ "@astrojs/node": "^11.1.7", "@fontsource/noto-naskh-arabic": "^5.3.0", "@fontsource/noto-nastaliq-urdu": "^5.3.0", + "@tiptap/core": "^3.31.4", + "@tiptap/extension-document": "^3.31.4", + "@tiptap/extension-text": "^3.31.4", + "@tiptap/extensions": "^3.31.4", + "@tiptap/pm": "^3.31.4", "astro": "^7.3.8" } } diff --git a/web/src/layouts/Base.astro b/web/src/layouts/Base.astro index 97232cd6..efdcc3f9 100644 --- a/web/src/layouts/Base.astro +++ b/web/src/layouts/Base.astro @@ -115,6 +115,8 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک document.addEventListener('focusin', (e) => { if (e.target.matches?.('[data-urdu]')) target = e.target; }); const typed = () => target.dispatchEvent(new Event('input', { bubbles: true })); // so suggestions update const insert = (text) => { + // rich editors (the content editor) take typed text through the browser's own editing command + if (target.isContentEditable) { target.focus(); document.execCommand('insertText', false, text); return; } const s = target.selectionStart ?? target.value.length, e = target.selectionEnd ?? target.value.length; target.setRangeText(text, s, e, 'end'); target.focus(); typed(); }; @@ -123,6 +125,7 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک const b = ev.target.closest('button'); if (!b) return; if (b.dataset.act === 'shift') { shift = !shift; render(); return; } if (b.dataset.act === 'bs') { + if (target.isContentEditable) { target.focus(); document.execCommand('delete'); return; } const s = target.selectionStart, e = target.selectionEnd; target.setRangeText('', s === e ? Math.max(0, s - 1) : s, e, 'end'); target.focus(); typed(); return; @@ -137,7 +140,7 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک // other inputs can open the same keyboard / Roman typing with their own buttons document.addEventListener('click', (e) => { const b = e.target.closest('[data-urdu-kb], [data-urdu-rom]'); if (!b) return; - target = document.getElementById(b.dataset.urduKb ?? b.dataset.urduRom); + target = document.getElementById(b.dataset.urduKb ?? b.dataset.urduRom)?.querySelector('[contenteditable]') ?? document.getElementById(b.dataset.urduKb ?? b.dataset.urduRom); (b.dataset.urduKb ? kbBtn : romBtn).click(); b.setAttribute('aria-pressed', (b.dataset.urduKb ? kbBtn : romBtn).getAttribute('aria-pressed')); }); @@ -327,7 +330,7 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک clearTimeout(selTimer); selTimer = setTimeout(() => { const sel = getSelection(); - const inText = sel && !sel.isCollapsed && main.contains(sel.anchorNode) && !sel.anchorNode.parentElement?.closest('form, input, textarea'); + const inText = sel && !sel.isCollapsed && main.contains(sel.anchorNode) && !sel.anchorNode.parentElement?.closest('form, input, textarea, [contenteditable]'); // not while editing selText = inText ? sel.toString().replace(/\u0614/g, '').trim() : ''; if (!selText) return hideMenu(); const w = oneWord(selText); diff --git a/web/src/lib/editor.ts b/web/src/lib/editor.ts new file mode 100644 index 00000000..91bc5c64 --- /dev/null +++ b/web/src/lib/editor.ts @@ -0,0 +1,163 @@ +// The Urdu content editor (#28) on Tiptap (MIT core, ProseMirror). It edits Divan text as structure: +// unit a verse unit of misras: 1 misra = مصرع, 2 = شعر (couplet), 3 or more = بند (stanza), as in Divan text; +// attribute label = مطلع | حسن مطلع | مقطع +// para a prose paragraph heading a chapter (level 2) or sub-heading (3, 4) title +// dict a word linked to the dictionary (mark, attribute lemma) +// note a footnote, variant a variant reading (inline atoms) +// Enter adds a misra to the unit (or moves into the empty next one); Enter on an empty misra starts a new unit +// (a blank line in Divan text). +// toDoc / fromEditor convert between Divan text and the editor, so the editor and the source view always agree. +import { Editor, Mark, Node, mergeAttributes } from '@tiptap/core'; +import Document from '@tiptap/extension-document'; +import Text from '@tiptap/extension-text'; +import { Placeholder, UndoRedo } from '@tiptap/extensions'; +import { parse, toText, type Doc, type Segment } from '../../../api/src/divantext.ts'; + +const Unit = Node.create({ + name: 'unit', group: 'block', content: 'misra+', defining: true, + addAttributes: () => ({ label: { default: null, parseHTML: (e) => e.getAttribute('data-mark'), renderHTML: (a) => (a.label ? { 'data-mark': a.label } : {}) } }), + parseHTML: () => [{ tag: 'div.unit' }], + renderHTML: ({ HTMLAttributes }) => ['div', mergeAttributes(HTMLAttributes, { class: 'unit' }), 0], + addKeyboardShortcuts() { + return { + // Enter on an empty misra: end this unit and start a new one (like a blank line in Divan text) + Enter: ({ editor }) => { + const { $from, empty } = editor.state.selection; + if (!empty || $from.parent.type.name !== 'misra') return false; + const unit = $from.node(-1), next = $from.index(-1) + 1; + // at the end of a misra followed by an empty one (a new couplet's second misra): move into it + if ($from.parentOffset === $from.parent.content.size && next < unit.childCount && !unit.child(next).content.size) + return editor.commands.setTextSelection($from.after() + 1); + if ($from.parent.content.size || unit.childCount < 2) return false; + const misraPos = $from.before(), unitEnd = $from.after(-1); + return editor.chain().command(({ tr }) => { + tr.delete(misraPos, misraPos + $from.parent.nodeSize); + return true; + }).insertContentAt(unitEnd - $from.parent.nodeSize, { type: 'unit', content: [{ type: 'misra' }] }).focus(unitEnd - $from.parent.nodeSize + 2).run(); + }, + }; + }, +}); +const Misra = Node.create({ + name: 'misra', content: 'inline*', + parseHTML: () => [{ tag: 'p.misra' }], renderHTML: () => ['p', { class: 'misra' }, 0], +}); +const Para = Node.create({ + name: 'para', group: 'block', content: 'inline*', + parseHTML: () => [{ tag: 'p.para' }], renderHTML: () => ['p', { class: 'para' }, 0], +}); +const Heading = Node.create({ + name: 'heading', group: 'block', content: 'text*', defining: true, + addAttributes: () => ({ level: { default: 2 } }), + parseHTML: () => [2, 3, 4].map((l) => ({ tag: `h${l + 1}`, attrs: { level: l } })), + renderHTML: ({ node }) => [`h${node.attrs.level + 1}`, { class: node.attrs.level > 2 ? 'mod-h sub' : 'mod-h' }, 0], +}); +const Dict = Mark.create({ + name: 'dict', inclusive: false, + addAttributes: () => ({ lemma: { default: null } }), + parseHTML: () => [{ tag: 'span.dv-dict' }], + renderHTML: ({ mark }) => ['span', { class: 'dv-dict', title: `لغت: ${mark.attrs.lemma}` }, 0], +}); +const Note = Node.create({ + name: 'note', group: 'inline', inline: true, atom: true, + addAttributes: () => ({ text: { default: '' } }), + parseHTML: () => [{ tag: 'sup.note' }], + renderHTML: ({ node }) => ['sup', { class: 'note', title: node.attrs.text, 'data-note': node.attrs.text }, '*'], +}); +const Variant = Node.create({ + name: 'variant', group: 'inline', inline: true, atom: true, + addAttributes: () => ({ shown: { default: '' }, others: { default: [] }, source: { default: null } }), + parseHTML: () => [{ tag: 'span.variant' }], + renderHTML: ({ node }) => ['span', { class: 'variant', title: `دوسرے نسخے: ${node.attrs.others.join('، ')}${node.attrs.source ? ` (${node.attrs.source})` : ''}` }, node.attrs.shown], +}); + +// ---- Divan text <-> editor content ---- + +const inlineContent = (segs: Segment[]) => segs.flatMap((s): any[] => + 'note' in s ? [{ type: 'note', attrs: { text: s.note } }] + : 'variant' in s ? [{ type: 'variant', attrs: { shown: s.variant.shown, others: s.variant.others, source: s.variant.source ?? null } }] + : !s.text ? [] : 'lemma' in s ? [{ type: 'text', text: s.text, marks: [{ type: 'dict', attrs: { lemma: s.lemma } }] }] + : [{ type: 'text', text: s.text }]); + +export function toEditor(doc: Doc) { + const content = doc.blocks.map((b): any => + b.type === 'heading' ? { type: 'heading', attrs: { level: b.level }, content: b.text ? [{ type: 'text', text: b.text }] : [] } + : b.type === 'para' ? { type: 'para', content: inlineContent(b.line.segments) } + : { type: 'unit', attrs: { label: b.label ?? null }, content: b.lines.map((l) => ({ type: 'misra', content: inlineContent(l.segments) })) }); + return { type: 'doc', content: content.length ? content : [{ type: 'unit', content: [{ type: 'misra' }, { type: 'misra' }] }] }; +} + +const segments = (node: any): Segment[] => (node.content ?? []).map((n: any): Segment => + n.type === 'note' ? { note: n.attrs.text } + : n.type === 'variant' ? { variant: { shown: n.attrs.shown, others: n.attrs.others, ...(n.attrs.source && { source: n.attrs.source }) } } + : n.marks?.find((m: any) => m.type === 'dict') ? { text: n.text, lemma: n.marks.find((m: any) => m.type === 'dict').attrs.lemma } + : { text: n.text }); +const lineOf = (node: any) => ({ text: '', words: [], notes: [], variants: [], segments: segments(node) }); + +// editor content (+ the work's data points) -> Divan text; empty misras and paragraphs are dropped +export function fromEditor(json: any, meta: Record) { + const blocks: Doc['blocks'] = []; + for (const n of json.content ?? []) { + const hasText = (x: any) => (x.content ?? []).some((c: any) => c.type !== 'text' || c.text.trim()); + if (n.type === 'heading') { const t = (n.content ?? []).map((c: any) => c.text).join('').trim(); if (t) blocks.push({ type: 'heading', text: t, level: n.attrs.level }); } + else if (n.type === 'para') { if (hasText(n)) blocks.push({ type: 'para', line: lineOf(n) }); } + else { + const lines = (n.content ?? []).filter(hasText).map(lineOf); + if (lines.length) blocks.push({ type: lines.length === 2 ? 'couplet' : lines.length === 1 ? 'line' : 'stanza', lines, ...(n.attrs?.label && { label: n.attrs.label }) }); + } + } + return toText({ meta, blocks }); +} + +export function createEditor(element: HTMLElement, text: string) { + const doc = parse(text); + const editor = new Editor({ + element, + extensions: [Document, Text, Unit, Misra, Para, Heading, Dict, Note, Variant, UndoRedo, + Placeholder.configure({ placeholder: ({ node }) => (node.type.name === 'misra' ? 'مصرع' : node.type.name === 'para' ? 'پیراگراف' : 'عنوان') })], + content: toEditor(doc), + editorProps: { attributes: { class: 'dv-editor poem', dir: 'rtl', 'data-urdu': '', spellcheck: 'false' } }, + }); + return { editor, meta: doc.meta }; +} + +// toolbar actions on the block at the cursor +export const actions = { + couplet: (e: Editor) => insertAfter(e, { type: 'unit', content: [{ type: 'misra' }, { type: 'misra' }] }), + para: (e: Editor) => insertAfter(e, { type: 'para' }), + heading: (e: Editor, level: number) => insertAfter(e, { type: 'heading', attrs: { level } }), + label: (e: Editor, label: string | null) => { + const { $from } = e.state.selection; + for (let d = $from.depth; d > 0; d--) if ($from.node(d).type.name === 'unit') { + const pos = $from.before(d), cur = $from.node(d).attrs.label; + return e.chain().command(({ tr }) => { tr.setNodeMarkup(pos, undefined, { label: cur === label ? null : label }); return true; }).focus().run(); + } + return false; + }, + dict: (e: Editor) => { + if (e.isActive('dict')) return e.chain().focus().unsetMark('dict').run(); + const { from, to } = e.state.selection; + const word = e.state.doc.textBetween(from, to).trim(); + if (!word) return alert('پہلے لفظ منتخب کریں'); + const lemma = prompt('لغت میں لفظ کی بنیادی شکل', word); + if (lemma) e.chain().focus().setMark('dict', { lemma: lemma.trim() }).run(); + }, + note: (e: Editor) => { + const text = prompt('حاشیہ'); + if (text?.trim()) e.chain().focus().insertContent({ type: 'note', attrs: { text: text.trim() } }).run(); + }, + variant: (e: Editor) => { + const { from, to } = e.state.selection; + const shown = (e.state.doc.textBetween(from, to).trim() || prompt('متن میں دکھایا جانے والا لفظ')) ?? ''; + const others = prompt('دوسرے نسخے کا متن (کئی ہوں تو ، سے الگ کریں)'); + if (!shown || !others) return; + const source = prompt('دوسرے نسخے کا نام') || null; + e.chain().focus().insertContentAt({ from, to }, { type: 'variant', attrs: { shown, others: others.split('،').map((s) => s.trim()).filter(Boolean), source } }).run(); + }, +}; + +function insertAfter(e: Editor, node: any) { + const { $from } = e.state.selection; + const pos = $from.depth > 0 ? $from.after(1) : e.state.doc.content.size; + return e.chain().insertContentAt(pos, node).focus(pos + (node.type === 'unit' ? 2 : 1)).run(); +} diff --git a/web/src/pages/mod/rev/[id].astro b/web/src/pages/mod/rev/[id].astro index 885bccf4..6bfd1af3 100644 --- a/web/src/pages/mod/rev/[id].astro +++ b/web/src/pages/mod/rev/[id].astro @@ -39,13 +39,39 @@ const acts = ([['approve', 'منظور کریں'], ['return', 'واپس بھیج {may.save ? (
-