Couplet tools in one row; share and download apart; Urdu keyboard in the note box
- Bookmark, note, share and download sit in one row centred under each couplet on the ✤ line (bookmark and note on one side, share and download on the other), clear of the text, the same on phones - Share always opens the share menu (the picture where the browser can share files, else the couplet and its link; with no share menu, both are copied); a new download button saves the picture - The note box has the Urdu keyboard and Roman typing like the search box (the keyboard moves into the box while it is open); notes can be in any language (the box follows the typed direction) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
bab7ffa37f
commit
f122eb8f10
@ -196,6 +196,7 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
|
|||||||
',':'،', '.':'۔', '?':'؟', ';':'؛',
|
',':'،', '.':'۔', '?':'؟', ';':'؛',
|
||||||
};
|
};
|
||||||
const q = document.getElementById('q'), kb = document.getElementById('kb'), kbBtn = document.getElementById('kb-toggle'), romBtn = document.getElementById('rom-toggle');
|
const q = document.getElementById('q'), kb = document.getElementById('kb'), kbBtn = document.getElementById('kb-toggle'), romBtn = document.getElementById('rom-toggle');
|
||||||
|
const kbHome = [kb.parentElement, kb.nextSibling]; // the keyboard's place in the header
|
||||||
let shift = false;
|
let shift = false;
|
||||||
const key = (ch) => ch ? `<button type="button" data-ch="${ch}">${ch}</button>` : '';
|
const key = (ch) => ch ? `<button type="button" data-ch="${ch}">${ch}</button>` : '';
|
||||||
const render = () => {
|
const render = () => {
|
||||||
@ -242,9 +243,17 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can
|
|||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
const b = e.target.closest('[data-urdu-kb], [data-urdu-rom]'); if (!b) return;
|
const b = e.target.closest('[data-urdu-kb], [data-urdu-rom]'); if (!b) return;
|
||||||
target = document.getElementById(b.dataset.urduKb ?? b.dataset.urduRom)?.querySelector('[contenteditable]') ?? 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);
|
||||||
|
// in an open dialog (the note box) the header can't be reached: the keyboard moves under the box, and back when it closes
|
||||||
|
const box = b.dataset.urduKb && target.closest('dialog');
|
||||||
|
if (box) target.closest('.in-box-field')?.after(kb); else if (b.dataset.urduKb) kbHome[0].insertBefore(kb, kbHome[1]);
|
||||||
(b.dataset.urduKb ? kbBtn : romBtn).click();
|
(b.dataset.urduKb ? kbBtn : romBtn).click();
|
||||||
b.setAttribute('aria-pressed', (b.dataset.urduKb ? kbBtn : romBtn).getAttribute('aria-pressed'));
|
b.setAttribute('aria-pressed', (b.dataset.urduKb ? kbBtn : romBtn).getAttribute('aria-pressed'));
|
||||||
});
|
});
|
||||||
|
document.addEventListener('close', (ev) => { // a dialog closing takes nothing of the header's with it
|
||||||
|
if (!ev.target.contains?.(kb)) return;
|
||||||
|
kb.hidden = true; kbBtn.setAttribute('aria-pressed', 'false'); kbHome[0].insertBefore(kb, kbHome[1]);
|
||||||
|
ev.target.querySelectorAll('[data-urdu-kb]').forEach((x) => x.setAttribute('aria-pressed', 'false'));
|
||||||
|
}, true);
|
||||||
document.addEventListener('keydown', (ev) => {
|
document.addEventListener('keydown', (ev) => {
|
||||||
if (!ev.target.matches?.('[data-urdu]')) return;
|
if (!ev.target.matches?.('[data-urdu]')) return;
|
||||||
if (romBtn.getAttribute('aria-pressed') !== 'true' || ev.ctrlKey || ev.metaKey || ev.altKey) return;
|
if (romBtn.getAttribute('aria-pressed') !== 'true' || ev.ctrlKey || ev.metaKey || ev.altKey) return;
|
||||||
|
|||||||
@ -148,12 +148,13 @@ import Icon from '../components/Icon.astro';
|
|||||||
{blocks.map((b) => {
|
{blocks.map((b) => {
|
||||||
const cn = noteOf(b.c), any = cn || notes.some((n) => n.couplet === b.c);
|
const cn = noteOf(b.c), any = cn || notes.some((n) => n.couplet === b.c);
|
||||||
// the bookmark, and beside it the note glyph (shown when this couplet or a phrase in it has a note)
|
// the bookmark, and beside it the note glyph (shown when this couplet or a phrase in it has a note)
|
||||||
const bm = <>
|
const bm = <span class="c-tools">
|
||||||
<button type="button" class="bm" data-save={bookmark(b.c)} aria-pressed={String(saved.couplets.includes(b.c))} aria-label="شعر محفوظ کریں"
|
<button type="button" class="bm" data-save={bookmark(b.c)} aria-pressed={String(saved.couplets.includes(b.c))} aria-label="شعر محفوظ کریں"
|
||||||
data-confirm={cn ? 'شعر محفوظ شدہ سے ہٹائیں؟ اس پر آپ کا نوٹ بھی ہٹ جائے گا۔' : undefined} />
|
data-confirm={cn ? 'شعر محفوظ شدہ سے ہٹائیں؟ اس پر آپ کا نوٹ بھی ہٹ جائے گا۔' : undefined} />
|
||||||
<button type="button" class="sh" data-c={b.c} aria-label="تصویر بنا کر بھیجیں" title="تصویر بنا کر بھیجیں" />
|
<button type="button" class="nt" data-for={b.c} aria-pressed={String(!!any)} aria-label={any ? 'نوٹ' : 'نوٹ لکھیں'} title={cn ? `نوٹ: ${cn}` : any ? 'اس شعر کی عبارتوں پر نوٹ' : 'نوٹ لکھیں'} />
|
||||||
<button type="button" class="nt" data-c={b.c} aria-pressed={String(!!any)} aria-label={any ? 'نوٹ' : 'نوٹ لکھیں'} title={cn ? `نوٹ: ${cn}` : any ? 'اس شعر کی عبارتوں پر نوٹ' : 'نوٹ لکھیں'} />
|
<button type="button" class="sh" data-for={b.c} aria-label="بھیجیں" title="تصویر بنا کر بھیجیں" />
|
||||||
</>;
|
<button type="button" class="dl" data-for={b.c} aria-label="تصویر ڈاؤن لوڈ کریں" title="تصویر ڈاؤن لوڈ کریں" />
|
||||||
|
</span>;
|
||||||
const lm = lineMarks(b.c, b.lines);
|
const lm = lineMarks(b.c, b.lines);
|
||||||
return b.kind === 'couplet' ? <div class="couplet" id={`c${b.c}`} data-c={b.c} data-mark={b.mark}>{bm}{b.lines.map((l, i) => <p><Seg s={show(l, lm[i])} /></p>)}<Tags tags={coupletTags(b.c)} /></div>
|
return b.kind === 'couplet' ? <div class="couplet" id={`c${b.c}`} data-c={b.c} data-mark={b.mark}>{bm}{b.lines.map((l, i) => <p><Seg s={show(l, lm[i])} /></p>)}<Tags tags={coupletTags(b.c)} /></div>
|
||||||
: b.kind === 'para' ? <p class="para" id={`c${b.c}`} data-c={b.c}>{bm}<Seg s={show(b.lines[0], lm[0])} /></p>
|
: b.kind === 'para' ? <p class="para" id={`c${b.c}`} data-c={b.c}>{bm}<Seg s={show(b.lines[0], lm[0])} /></p>
|
||||||
@ -164,7 +165,11 @@ import Icon from '../components/Icon.astro';
|
|||||||
<dialog class="ask note-box" id="note-box" aria-labelledby="note-for">
|
<dialog class="ask note-box" id="note-box" aria-labelledby="note-for">
|
||||||
<form method="dialog">
|
<form method="dialog">
|
||||||
<p id="note-for" class="note-for"></p>
|
<p id="note-for" class="note-for"></p>
|
||||||
<textarea name="note" rows="4" maxlength="1000" placeholder="آپ کا نوٹ…" aria-label="نوٹ" data-urdu></textarea>
|
<div class="in-box-field note-field">
|
||||||
|
<textarea id="note-text" name="note" rows="4" maxlength="1000" dir="auto" placeholder="آپ کا نوٹ، کسی بھی زبان میں…" aria-label="نوٹ" data-urdu></textarea>
|
||||||
|
<button type="button" class="in-box rom" data-urdu-rom="note-text" aria-pressed="false" aria-label="رومن حروف سے اردو" title="رومن حروف سے اردو ٹائپنگ (dil → دل)">اب</button>
|
||||||
|
<button type="button" class="in-box" data-urdu-kb="note-text" aria-pressed="false" aria-label="اردو کی بورڈ" title="اردو کی بورڈ"><Icon name="keyboard" size={18} /></button>
|
||||||
|
</div>
|
||||||
<ul class="note-others" hidden></ul>
|
<ul class="note-others" hidden></ul>
|
||||||
<div class="ask-actions">
|
<div class="ask-actions">
|
||||||
<button class="btn primary" value="save">محفوظ کریں</button><button class="btn" value="">بند کریں</button>
|
<button class="btn primary" value="save">محفوظ کریں</button><button class="btn" value="">بند کریں</button>
|
||||||
@ -224,10 +229,10 @@ import Icon from '../components/Icon.astro';
|
|||||||
box.showModal(); text.focus();
|
box.showModal(); text.focus();
|
||||||
};
|
};
|
||||||
const mark = (couplet: number) => { // the glyph follows the notes on this couplet
|
const mark = (couplet: number) => { // the glyph follows the notes on this couplet
|
||||||
const nt = document.querySelector(`.nt[data-c="${couplet}"]`); if (!nt) return;
|
const nt = document.querySelector(`.nt[data-for="${couplet}"]`); if (!nt) return;
|
||||||
const cn = notes.find((n) => n.couplet === couplet && !n.phrase)?.note, any = notes.some((n) => n.couplet === couplet);
|
const cn = notes.find((n) => n.couplet === couplet && !n.phrase)?.note, any = notes.some((n) => n.couplet === couplet);
|
||||||
nt.setAttribute('aria-pressed', String(any)); nt.setAttribute('title', cn ? `نوٹ: ${cn}` : any ? 'اس شعر کی عبارتوں پر نوٹ' : 'نوٹ لکھیں');
|
nt.setAttribute('aria-pressed', String(any)); nt.setAttribute('title', cn ? `نوٹ: ${cn}` : any ? 'اس شعر کی عبارتوں پر نوٹ' : 'نوٹ لکھیں');
|
||||||
const bm = document.querySelector(`[data-c="${couplet}"] > .bm, p[data-c="${couplet}"] > .bm`) as HTMLElement | null;
|
const bm = document.querySelector(`#c${couplet} .bm`) as HTMLElement | null;
|
||||||
if (bm) { if (cn) { bm.setAttribute('aria-pressed', 'true'); bm.dataset.confirm = 'شعر محفوظ شدہ سے ہٹائیں؟ اس پر آپ کا نوٹ بھی ہٹ جائے گا۔'; } else delete bm.dataset.confirm; }
|
if (bm) { if (cn) { bm.setAttribute('aria-pressed', 'true'); bm.dataset.confirm = 'شعر محفوظ شدہ سے ہٹائیں؟ اس پر آپ کا نوٹ بھی ہٹ جائے گا۔'; } else delete bm.dataset.confirm; }
|
||||||
};
|
};
|
||||||
box.addEventListener('close', async () => {
|
box.addEventListener('close', async () => {
|
||||||
@ -245,14 +250,14 @@ import Icon from '../components/Icon.astro';
|
|||||||
});
|
});
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
const t = e.target as HTMLElement;
|
const t = e.target as HTMLElement;
|
||||||
const nt = t.closest('.nt') as HTMLElement | null; if (nt) return open(Number(nt.dataset.c), null);
|
const nt = t.closest('.nt') as HTMLElement | null; if (nt) return open(Number(nt.dataset.for), null);
|
||||||
const ph = t.closest('.bmk[data-phrase]') as HTMLElement | null;
|
const ph = t.closest('.bmk[data-phrase]') as HTMLElement | null;
|
||||||
if (ph && !getSelection()?.toString().trim()) { const p = JSON.parse(ph.dataset.phrase!); open(p.couplet, p.phrase); }
|
if (ph && !getSelection()?.toString().trim()) { const p = JSON.parse(ph.dataset.phrase!); open(p.couplet, p.phrase); }
|
||||||
});
|
});
|
||||||
document.addEventListener('keydown', (e) => { const ph = (e.target as HTMLElement).closest?.('.bmk[data-phrase]') as HTMLElement | null;
|
document.addEventListener('keydown', (e) => { const ph = (e.target as HTMLElement).closest?.('.bmk[data-phrase]') as HTMLElement | null;
|
||||||
if (ph && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); const p = JSON.parse(ph.dataset.phrase!); open(p.couplet, p.phrase); } });
|
if (ph && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); const p = JSON.parse(ph.dataset.phrase!); open(p.couplet, p.phrase); } });
|
||||||
// share a couplet as an image: a square card (frame, logo, the lines, the poet), drawn here and handed to the share sheet
|
// share a couplet as an image: a square card (frame, logo, the lines, the poet), drawn here and handed to the share sheet
|
||||||
const share = async (c: number) => {
|
const share = async (c: number, download = false) => {
|
||||||
const [poet, title] = (document.querySelector('[data-cite]') as HTMLElement).dataset.cite!.split('، ');
|
const [poet, title] = (document.querySelector('[data-cite]') as HTMLElement).dataset.cite!.split('، ');
|
||||||
const prose = !!document.querySelector(`p.para[data-c="${c}"]`);
|
const prose = !!document.querySelector(`p.para[data-c="${c}"]`);
|
||||||
const lines = linesOf(c), S = 1080, cv = document.createElement('canvas'); cv.width = cv.height = S;
|
const lines = linesOf(c), S = 1080, cv = document.createElement('canvas'); cv.width = cv.height = S;
|
||||||
@ -290,10 +295,14 @@ import Icon from '../components/Icon.astro';
|
|||||||
const blob: Blob = await new Promise((done) => cv.toBlob((b) => done(b!), 'image/png'));
|
const blob: Blob = await new Promise((done) => cv.toBlob((b) => done(b!), 'image/png'));
|
||||||
const file = new File([blob], `divan-${poemId}-${c + 1}.png`, { type: 'image/png' });
|
const file = new File([blob], `divan-${poemId}-${c + 1}.png`, { type: 'image/png' });
|
||||||
const url = `${location.origin}${location.pathname}#c${c}`;
|
const url = `${location.origin}${location.pathname}#c${c}`;
|
||||||
|
if (download) { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = file.name; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 2000); (window as any).toast('تصویر ڈاؤن لوڈ ہو گئی'); return; }
|
||||||
|
// the share menu: with the picture where the browser can share files, otherwise the couplet and its link; never a download
|
||||||
|
const text = `${lines.join('\n')}\n— ${poet}`;
|
||||||
if (navigator.canShare?.({ files: [file] })) await navigator.share({ files: [file], text: url }).catch(() => {});
|
if (navigator.canShare?.({ files: [file] })) await navigator.share({ files: [file], text: url }).catch(() => {});
|
||||||
else { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = file.name; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 2000); (window as any).toast('تصویر ڈاؤن لوڈ ہو گئی'); }
|
else if (navigator.share) await navigator.share({ title: document.title, text, url }).catch(() => {});
|
||||||
|
else { try { await navigator.clipboard.writeText(`${text}\n${url}`); (window as any).toast('یہ براؤزر شیئر نہیں کر سکتا: شعر اور لنک کاپی ہو گئے'); } catch { (window as any).toast('شیئر نہیں ہو سکا', 'error'); } }
|
||||||
};
|
};
|
||||||
document.addEventListener('click', (e) => { const sh = (e.target as HTMLElement).closest('.sh') as HTMLElement | null; if (sh) share(Number(sh.dataset.c)); });
|
document.addEventListener('click', (e) => { const b = (e.target as HTMLElement).closest('.sh, .dl') as HTMLElement | null; if (b) share(Number(b.dataset.for), b.classList.contains('dl')); });
|
||||||
document.addEventListener('divan:note', (e) => { const { couplet, phrase } = (e as CustomEvent).detail; open(couplet, phrase); });
|
document.addEventListener('divan:note', (e) => { const { couplet, phrase } = (e as CustomEvent).detail; open(couplet, phrase); });
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -729,6 +729,10 @@ p, li { text-wrap: pretty; }
|
|||||||
.note-box { width: min(480px, 92vw); }
|
.note-box { width: min(480px, 92vw); }
|
||||||
.note-for { font-size: var(--fs-md); color: var(--text-muted); max-height: 6.5em; overflow: auto; }
|
.note-for { font-size: var(--fs-md); color: var(--text-muted); max-height: 6.5em; overflow: auto; }
|
||||||
.note-box textarea { width: 100%; box-sizing: border-box; resize: vertical; line-height: 1.9; margin-bottom: 10px; }
|
.note-box textarea { width: 100%; box-sizing: border-box; resize: vertical; line-height: 1.9; margin-bottom: 10px; }
|
||||||
|
.note-field .in-box { top: 20px; } /* at the first line of the box, not its middle */
|
||||||
|
.note-field textarea { padding-left: 80px; } /* the buttons sit on the left whatever the note's language (the box follows what is typed) */
|
||||||
|
.note-box .kb { padding: 0 0 10px; }
|
||||||
|
.note-box .kb button { min-width: 2em; font-size: 1rem; }
|
||||||
.note-others { list-style: none; margin: 0 0 10px; padding: 8px 0 0; border-top: 1px dashed var(--line); }
|
.note-others { list-style: none; margin: 0 0 10px; padding: 8px 0 0; border-top: 1px dashed var(--line); }
|
||||||
.note-others button { all: unset; display: block; cursor: pointer; font-size: var(--fs-sm); color: var(--text-muted); padding: 2px 0; }
|
.note-others button { all: unset; display: block; cursor: pointer; font-size: var(--fs-sm); color: var(--text-muted); padding: 2px 0; }
|
||||||
.note-others button:hover { color: var(--active); }
|
.note-others button:hover { color: var(--active); }
|
||||||
@ -776,3 +780,18 @@ p, li { text-wrap: pretty; }
|
|||||||
.timeline blockquote { margin: 4px 0; padding-inline-start: 10px; border-inline-start: 2px solid var(--tone); color: var(--text-muted); font-size: var(--fs-sm); }
|
.timeline blockquote { margin: 4px 0; padding-inline-start: 10px; border-inline-start: 2px solid var(--tone); color: var(--text-muted); font-size: var(--fs-sm); }
|
||||||
[data-tone] { --tone: var(--text-muted); }
|
[data-tone] { --tone: var(--text-muted); }
|
||||||
[data-tone='ok'] { --tone: var(--ok); } [data-tone='action'] { --tone: var(--action); } [data-tone='accent'] { --tone: var(--accent); } [data-tone='danger'] { --tone: var(--danger); }
|
[data-tone='ok'] { --tone: var(--ok); } [data-tone='action'] { --tone: var(--action); } [data-tone='accent'] { --tone: var(--accent); } [data-tone='danger'] { --tone: var(--danger); }
|
||||||
|
|
||||||
|
/* a couplet's bookmark, note and share sit in one row, centred under it on the ✤ line (bookmark and note on one side
|
||||||
|
of the ✤, share on the other): clear of the text however long the misras are, the same on phones */
|
||||||
|
.c-tools { position: absolute; left: 50%; bottom: -4px; transform: translateX(-50%); z-index: 1; display: flex; align-items: center; gap: 4px; }
|
||||||
|
.c-tools > button { position: static; inset: auto; top: auto; }
|
||||||
|
.c-tools > .sh { margin-inline-start: 26px; } /* room for the ✤ between */
|
||||||
|
.poem .para > .c-tools, .poem .single > .c-tools { bottom: -20px; }
|
||||||
|
.poem .para, .poem .single { margin-bottom: 26px; }
|
||||||
|
@media (max-width: 760px) { .c-tools { bottom: 2px; } .c-tools > .sh { margin-inline-start: 4px; } .couplet { padding-bottom: 28px; } }
|
||||||
|
/* download the couplet's picture: beside share */
|
||||||
|
.dl { width: 20px; height: 20px; padding: 0; border: 0; background: none; cursor: pointer; opacity: 0; transition: opacity .15s; }
|
||||||
|
.dl::before { content: ''; display: block; width: 16px; height: 16px; margin: auto; background-color: var(--text-muted); -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'/%3E%3Cpath d='m7 10 5 5 5-5'/%3E%3Cpath d='M12 15V3'/%3E%3C/svg%3E") center / contain no-repeat; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'/%3E%3Cpath d='m7 10 5 5 5-5'/%3E%3Cpath d='M12 15V3'/%3E%3C/svg%3E") center / contain no-repeat; }
|
||||||
|
.dl:hover::before { background-color: var(--active); }
|
||||||
|
.couplet:hover .dl, .poem .para:hover .dl, .poem .single:hover .dl, .dl:focus-visible { opacity: 1; }
|
||||||
|
@media (hover: none) { .dl { opacity: .55; } }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user