Urdu content editor on Tiptap (structured editing, with the source view) #58
@ -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> اور {{نسخہ|الف|ب|ماخذ=س}}');
|
||||
});
|
||||
|
||||
@ -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
240
web/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
163
web/src/lib/editor.ts
Normal 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();
|
||||
}
|
||||
@ -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><ref>…</ref></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>
|
||||
|
||||
@ -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; } }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user