Urdu content editor on Tiptap (structured editing, with the source view) #58

Merged
anas merged 2 commits from feature/tiptap-editor into main 2026-10-08 23:13:21 +00:00
8 changed files with 593 additions and 21 deletions

View File

@ -62,3 +62,16 @@ test('TEI export is well-formed XML with couplets as <lg type="sher"> of two <l>
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('<poem>\n{{مقطع}} [[لغت:دیوانہ|دیوانے]] ہیں<ref>حاشیہ</ref> {{نسخہ|الف|ب|ماخذ=س}}\nدوسرا\n</poem>')]) {
const again = parse(toText(d));
assert.deepEqual(again.meta, d.meta);
assert.deepEqual(again.blocks, d.blocks);
}
const l = inline('کوئی [[لغت:دیوانہ|دیوانے]] ہیں<ref>نوٹ</ref> اور {{نسخہ|الف|ب|ماخذ=س}}');
assert.deepEqual(l.segments, [{ text: 'کوئی ' }, { lemma: 'دیوانہ', text: 'دیوانے' }, { text: ' ہیں' }, { note: 'نوٹ' }, { text: ' اور ' },
{ variant: { shown: 'الف', others: ['ب'], source: 'س' } }]);
assert.equal(segmentsText(l.segments), 'کوئی [[لغت:دیوانہ|دیوانے]] ہیں<ref>نوٹ</ref> اور {{نسخہ|الف|ب|ماخذ=س}}');
});

View File

@ -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 = /<ref>([\s\S]*?)<\/ref>|(\{\{نسخہ\|[^{}]*\}\})|\[\[(?:لغت:)?([^\]|]+)(?:\|([^\]]+))?\]\]/g;
export function inline(src: string): Inline {
const words: Inline['words'] = [], notes: string[] = [], variants: Inline['variants'] = [];
let text = src
.replace(/<ref>([\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 ? `<ref>${x.note}</ref>`
: '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(`<poem>\n${verse.join('\n\n')}\n</poem>`); 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 {

240
web/package-lock.json generated
View File

@ -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",

View File

@ -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"
}
}

View File

@ -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);

163
web/src/lib/editor.ts Normal file
View File

@ -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<string, string>) {
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();
}

View File

@ -39,13 +39,39 @@ const acts = ([['approve', 'منظور کریں'], ['return', 'واپس بھیج
{may.save ? (
<form method="post" class="mod-edit">
<label>ترمیم کا خلاصہ<input name="summary" value={rev.summary ?? ''} maxlength="500" data-urdu placeholder="مثلاً: دوسرے شعر کا مصرع درست کیا" /></label>
<label class="mod-text">متن (دیوان متن)
<nav class="admin-nav editor-tabs" role="tablist">
<button type="button" role="tab" data-tab="editor" aria-selected="true">ایڈیٹر</button>
<button type="button" role="tab" data-tab="source" aria-selected="false">سورس (دیوان متن)</button>
</nav>
<div id="editor-pane">
<fieldset class="meta-fields"><legend>کلام کا خاکہ</legend><div id="meta-rows"></div>
<button type="button" id="meta-add" class="tool">+ معلومات</button></fieldset>
<div class="toolbar" role="toolbar" aria-label="ایڈیٹر">
<button type="button" class="tool" data-cmd="couplet" title="نیا شعر (دو مصرعے)">شعر</button>
<button type="button" class="tool" data-cmd="para" title="نثر کا نیا پیراگراف">پیراگراف</button>
<button type="button" class="tool" data-cmd="h2" title="باب کا عنوان">باب کا عنوان</button>
<button type="button" class="tool" data-cmd="h3" title="ذیلی عنوان">ذیلی عنوان</button>
<span class="sep"></span>
<button type="button" class="tool" data-cmd="label" data-label="مطلع">مطلع</button>
<button type="button" class="tool" data-cmd="label" data-label="حسن مطلع">حسن مطلع</button>
<button type="button" class="tool" data-cmd="label" data-label="مقطع">مقطع</button>
<span class="sep"></span>
<button type="button" class="tool" data-cmd="dict" title="منتخب لفظ کو لغت سے جوڑیں">لغت کا ربط</button>
<button type="button" class="tool" data-cmd="note" title="حاشیہ لگائیں">حاشیہ</button>
<button type="button" class="tool" data-cmd="variant" title="نسخوں کا فرق">نسخہ</button>
<span class="sep"></span>
<button type="button" class="tool" data-cmd="undo" title="پچھلی تبدیلی واپس">واپس</button>
<button type="button" class="tool" data-cmd="redo" title="تبدیلی دوبارہ">دوبارہ</button>
<button type="button" class="tool" data-urdu-rom="dv-editor-host" title="رومن حروف سے اردو">اب</button>
<button type="button" class="tool" data-urdu-kb="dv-editor-host" title="اردو کی بورڈ">کی بورڈ</button>
</div>
<div id="dv-editor-host"></div>
<p class="muted note">Enter: اسی شعر یا بند میں نیا مصرع · خالی مصرع پر Enter: نیا شعر · <a href="/mod/guide" target="_blank">لکھنے کا مکمل طریقہ</a></p>
</div>
<label class="mod-text" id="source-pane" hidden>متن (دیوان متن)
<textarea name="content" id="mod-content" data-urdu rows="22" dir="rtl" spellcheck="false">{rev.content}</textarea>
</label>
<p class="muted note">شعر کے دو مصرعے الگ سطروں پر، اشعار کے درمیان خالی سطر۔ <code>== باب ==</code> عنوان، <code>[[لفظ]]</code> لغت کا ربط، <code>&lt;ref&gt;…&lt;/ref&gt;</code> حاشیہ۔ <a href="/mod/guide" target="_blank">لکھنے کا مکمل طریقہ</a></p>
<div class="filter-actions">
<button type="button" class="in-box-btn" data-urdu-rom="mod-content" title="رومن حروف سے اردو">اب</button>
<button type="button" class="in-box-btn" data-urdu-kb="mod-content" title="اردو کی بورڈ">کی بورڈ</button>
<button name="act" value="save">محفوظ کریں</button>
<button name="act" value="submit">{me.role === 'admin' ? 'محفوظ کر کے شائع کریں' : me.role === 'mod-l1' ? 'محفوظ کر کے اشاعت کے لیے بھیجیں' : 'محفوظ کر کے جائزے کے لیے بھیجیں'}</button>
</div>
@ -81,4 +107,56 @@ const acts = ([['approve', 'منظور کریں'], ['return', 'واپس بھیج
))}
</ol>
</section>
<script>
// the structured editor (Tiptap) and the source view edit the same Divan text; saving always sends the text
import { createEditor, fromEditor, toEditor, actions } from '../../../lib/editor';
import { parse } from '../../../../../api/src/divantext.ts';
const host = document.getElementById('dv-editor-host');
const source = document.getElementById('mod-content') as HTMLTextAreaElement | null;
if (host && source) {
const { editor, meta } = createEditor(host, source.value);
const rows = document.getElementById('meta-rows')!;
const KNOWN = ['عنوان', 'شاعر', 'مصنف', 'صنف', 'ردیف', 'قافیہ', 'بحر', 'ماخذ', 'ماخذ_ربط'];
const row = (k = '', v = '') => {
const r = document.createElement('div'); r.className = 'meta-row';
r.innerHTML = '<input class="meta-k" list="meta-keys" aria-label="معلومات کا نام"><input class="meta-v" data-urdu aria-label="قدر"><button type="button" class="tool" aria-label="ہٹائیں">✕</button>';
(r.children[0] as HTMLInputElement).value = k; (r.children[1] as HTMLInputElement).value = v;
(r.children[2] as HTMLButtonElement).onclick = () => r.remove();
rows.append(r);
};
const fillMeta = (m: Record<string, string>) => { rows.replaceChildren(); Object.entries(m).forEach(([k, v]) => row(k, v)); if (!rows.children.length) row('عنوان', ''); };
const readMeta = () => Object.fromEntries([...rows.querySelectorAll('.meta-row')].map((r) => [(r.children[0] as HTMLInputElement).value.trim(), (r.children[1] as HTMLInputElement).value.trim()]).filter(([k, v]) => k && v));
const keys = document.createElement('datalist'); keys.id = 'meta-keys'; keys.innerHTML = KNOWN.map((k) => `<option value="${k}">`).join(''); document.body.append(keys);
fillMeta(meta);
document.getElementById('meta-add')!.onclick = () => row();
const sync = () => { source.value = fromEditor(editor.getJSON(), readMeta()); };
let tab = 'editor';
document.querySelectorAll<HTMLButtonElement>('[data-tab]').forEach((b) => (b.onclick = () => {
if (b.dataset.tab === tab) return;
if (tab === 'editor') sync();
else { const doc = parse(source.value); editor.commands.setContent(toEditor(doc)); fillMeta(doc.meta); }
tab = b.dataset.tab!;
document.getElementById('editor-pane')!.hidden = tab !== 'editor';
document.getElementById('source-pane')!.hidden = tab !== 'source';
document.querySelectorAll('[data-tab]').forEach((t) => t.setAttribute('aria-selected', String(t === b)));
}));
source.form!.addEventListener('submit', () => { if (tab === 'editor') sync(); });
// toolbar buttons must not take the focus (and the selected word) away from the editor
document.querySelector('.toolbar')!.addEventListener('mousedown', (e) => e.preventDefault());
document.querySelector('.toolbar')!.addEventListener('click', (e) => {
const b = (e.target as HTMLElement).closest<HTMLButtonElement>('[data-cmd]'); if (!b) return;
const c = b.dataset.cmd!;
if (c === 'undo') editor.chain().focus().undo().run();
else if (c === 'redo') editor.chain().focus().redo().run();
else if (c === 'h2' || c === 'h3') actions.heading(editor, c === 'h2' ? 2 : 3);
else if (c === 'label') actions.label(editor, b.dataset.label!);
else (actions as any)[c](editor);
});
// a note or variant shows its text when clicked
host.addEventListener('click', (e) => {
const n = (e.target as HTMLElement).closest('sup.note, span.variant');
if (n) alert(n.getAttribute('title'));
});
}
</script>
</Base>

View File

@ -346,3 +346,33 @@ h1 + .muted { text-align: center; margin-top: 0; }
.preview sup { color: var(--brand); font-size: .7em; }
.preview-notes { font-size: .85rem; border-top: 1px dashed var(--gold-light); margin-top: 8px; padding-top: 6px; }
@media (max-width: 760px) { .example { grid-template-columns: 1fr; } }
/* the structured content editor (Tiptap) */
.editor-tabs button { font: inherit; padding: 2px 14px; border: 1.5px solid var(--border); border-radius: 999px; background: var(--inner); color: var(--ink); cursor: pointer; }
.editor-tabs button[aria-selected='true'] { border-color: var(--brand); color: var(--brand); }
.meta-fields { border: 1px dashed var(--gold-light); border-radius: 10px; padding: 6px 12px; }
.meta-fields legend { font-size: .85rem; padding: 0 6px; }
.meta-row { display: grid; grid-template-columns: 9em 1fr auto; gap: 6px; margin: 4px 0; }
.meta-row input { font: inherit; font-size: .9rem; padding: 2px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--paper); color: var(--ink); }
.toolbar { position: sticky; top: 0; z-index: 5; display: flex; flex-wrap: wrap; gap: 4px; padding: 6px; background: var(--paper); border: 1px solid var(--border); border-radius: 10px; }
.toolbar .sep { width: 1px; background: var(--gold-light); margin: 0 4px; }
.tool { font: inherit; font-size: .85rem; padding: 0 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--inner); color: var(--ink); cursor: pointer; }
.tool:hover, .tool[aria-pressed='true'] { border-color: var(--brand); color: var(--brand); }
.dv-editor { min-height: 20em; outline: none; margin: 8px 0; }
/* Tiptap's own CSS turns ligatures off, which breaks Nastaliq (and Naskh's لا); keep the font's shaping */
.ProseMirror.dv-editor { font-variant-ligatures: normal; font-feature-settings: normal; }
.dv-editor:focus-within { border-color: var(--gold); }
.dv-editor .unit { position: relative; margin: 0 0 10px; padding: 4px 8px; border-radius: 8px; border: 1px dashed transparent; }
.dv-editor .unit:hover { border-color: var(--gold-light); }
.dv-editor .unit:has(> .misra:nth-child(2):last-child) { display: grid; grid-template-columns: 1fr 1fr; column-gap: 10%; }
.dv-editor .unit .misra { margin: 0; text-align: center; min-height: 1.8em; }
.dv-editor .unit:has(> .misra:nth-child(2):last-child) .misra:first-child { text-align: left; }
.dv-editor .unit:has(> .misra:nth-child(2):last-child) .misra:last-child { text-align: right; }
.dv-editor .unit:has(> .misra:nth-child(3)) { border-inline-start: 2px solid var(--gold-light); }
.dv-editor .unit[data-mark]::before { content: attr(data-mark); grid-column: 1 / -1; justify-self: center; font-size: .72rem; color: var(--brand); border: 1px solid var(--gold-light); border-radius: 999px; padding: 0 10px; }
.dv-editor .para { min-height: 1.8em; }
.dv-editor .dv-dict { text-decoration: underline dotted var(--lapis) 2px; text-underline-offset: 5px; }
.dv-editor sup.note { color: var(--brand); cursor: pointer; padding: 0 2px; }
.dv-editor .variant { border-bottom: 2px dashed var(--gold); cursor: pointer; }
.dv-editor .is-empty::before { content: attr(data-placeholder); color: var(--muted); opacity: .6; pointer-events: none; float: inline-start; height: 0; }
@media (max-width: 700px) { .dv-editor .unit:has(> .misra:nth-child(2):last-child) { grid-template-columns: 1fr; } .dv-editor .unit .misra { text-align: center !important; } .meta-row { grid-template-columns: 1fr; } }