diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d188b8..234a762 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- **Save ink into file works into a different board copy in Chrome** (v36, HTML overlay). Saving from `pet_board_inked.html` into `pet_board_clean.html` now asks Chrome's "allow editing?" question straight after you pick the file, before OverBoard's own "different file name" question (which used up the tap Chrome needs). If the browser still won't ask, the button stays in view and says **Tap again to allow**. +- **Ink saved into a file always shows when that file opens** (v36). The file now notes when the ink was written into it, so a browser that had used that copy before (even just to try it and clear it) no longer shows its own older, empty copy instead. - **Tested: old board copies upgrade cleanly** (v35). Dropping an older `_board.html` on the PDF app keeps its name (no `_board_board`), puts in the newest toolbar, and the ink saved inside it still opens. A test now checks this end to end, starting from a v13 board copy. - **Select** (v34, PDF app and HTML overlay): a new **Select** button beside ⏱ Timer. Circle ink with the pen (or tap one mark) and a dashed blue box shows what's picked, with a menu beside it: - **Send a copy to** another class period (P2, P3…): the copy lands in the same spot in that period's ink. Tap more than one period to send it to each. diff --git a/app/index.html b/app/index.html index fa8e43e..57ac23b 100644 --- a/app/index.html +++ b/app/index.html @@ -5,7 +5,7 @@ OverBoard PDF - + @@ -45,7 +45,7 @@

Open again

Your PDF and your ink stay in this browser on this computer. Nothing is uploaded. Have a Word file? In Word, choose File → Save as PDF, then drop the PDF here. Have an HTML worksheet? Drop it here to get a _board.html copy you can write on. -
Back to the HTML worksheet demo · About OverBoard · Board not working? · v35

+
Back to the HTML worksheet demo · About OverBoard · Board not working? · v36

diff --git a/app/report/index.html b/app/report/index.html index 78b2f76..2d9588e 100644 --- a/app/report/index.html +++ b/app/report/index.html @@ -97,7 +97,7 @@

Is OverBoard not working on your board?

If something doesn't work the way the page asks, that's fine. That's exactly what I need to know. Just keep going.

Nothing is sent until you press Send at the very end, and you'll see everything first. No worksheets, PDFs or ink are ever included.

-

OverBoard v35

+

OverBoard v36

diff --git a/app/sw.js b/app/sw.js index e4d38bb..b49c960 100644 --- a/app/sw.js +++ b/app/sw.js @@ -1,9 +1,9 @@ // ===== GENERATED by tools/build.py: every file the app needs, with the start of its SHA-256. Do not edit by hand. ===== -const VERSION = '9b25193c784a'; +const VERSION = '471e7c21ebea'; const FILES = { - "../overlay/ink_overlay.html": "0e27675d1ec4908f", + "../overlay/ink_overlay.html": "42bade453c738a0a", "../shared/ink-core.js": "84be8f49eeb4d2d3", - "../shared/strings.js": "e84c83bf93320729", + "../shared/strings.js": "d85fbc3cf3f1c436", "../shared/toolbar.css": "55e88770a7c0f1c2", "app.css": "eb0f282d02685caf", "embed.js": "ea50a9ff4e960dd4", @@ -14,14 +14,14 @@ const FILES = { "icons/icon-192.png": "d245e15b36475003", "icons/icon-512.png": "17c1021fe2a0ade1", "icons/maskable-512.png": "0c58f1bdccc9eda9", - "index.html": "74f77552bac14322", + "index.html": "c03b8fc0802c288d", "ink.js": "253f8d907ccfbc1b", "libs.js": "c09f60186ab9029a", "main.js": "48ecaa409acb49b9", "manifest.json": "f7ee4a6c6f83cd40", "notebook.js": "47bcfd4f80712344", "offline.js": "6504935b2738711a", - "report/index.html": "720cab90cc9b146f", + "report/index.html": "9c8cd50b68eafb8c", "store.js": "66af54cd32e6cbd2", "textlayer.js": "1911308ed3e2a8b5", "vendor/pdf-lib@1.17.1/LICENSE.md": "f2c9fc00fdb66eb9", @@ -214,7 +214,7 @@ const FILES = { "vendor/pdfjs-dist@4.10.38/standard_fonts/LiberationSans-BoldItalic.ttf": "a224075ac17495ad", "vendor/pdfjs-dist@4.10.38/standard_fonts/LiberationSans-Italic.ttf": "832b4406dbef2362", "vendor/pdfjs-dist@4.10.38/standard_fonts/LiberationSans-Regular.ttf": "f8ace1f892b2bd9d", - "version.json": "f2e202f1f6845553", + "version.json": "0c4d0798795a815b", "viewer.js": "648b4479c5b68b93" }; // ===== END GENERATED ===== diff --git a/app/version.json b/app/version.json index 3dd3e1d..19641e2 100644 --- a/app/version.json +++ b/app/version.json @@ -1,5 +1,5 @@ { - "v": "a1a6d509158d", + "v": "38fc581fca25", "js": [ "embed.js", "export.js", diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bc932c2..8c92d8f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -12,7 +12,7 @@ Notes for contributors: how each part is built and why. What's planned next is i - **Layers.** Pen ink on `#ink-canvas`; highlights on `#ink-hl` underneath with `mix-blend-mode: multiply`; eraser strokes cut through both. - **Rendering.** Strokes are quadratic curves through sample midpoints, with an exponential smoothing filter on input (`SMOOTHING` level 0–8). Live drawing renders the identical curve piece by piece. - **Input (SMART Board on macOS, observed with `tools/board-probe.html`).** Pen tip → `pointerType: "pen"`, `buttons: 1`. Palm/fist/side of hand → `"pen"` with `buttons: 32` (eraser). Finger and the pen's round end → `"mouse"`. Contact size is always 1×1. Only one pointer reaches the browser at a time. A still finger is held back by the driver until it moves. -- **Saving.** Browser IndexedDB (database `ink-data`, store `ink`, key `ink-v2::`; v21+), plus optional File System Access write into the `<script id="ink-data">` block of the .html file. +- **Saving.** Browser IndexedDB (database `ink-data`, store `ink`, key `ink-v2:<path>:<title>`; v21+), plus optional File System Access write into the `<script id="ink-data">` block of the .html file. Ink written into the file carries `w` (when it was written; v36). On load, the file's ink counts as new as `max(t, w)` against the browser's copy (`t` = last change), so ink copied into another board copy beats that copy's older browser ink. Linking a file asks for write permission straight after the file window, before the "different file name" `confirm()`, which in Chrome uses up the tap the permission prompt needs; if the browser still refuses, the File menu stays open with **Tap again to allow** on the button. - **Print.** `beforeprint` redraws each page's strokes as vectors at `PRINT_SCALE` onto per-page canvases. ## The PDF app (v2) diff --git a/index.html b/index.html index fde115b..0e1897d 100644 --- a/index.html +++ b/index.html @@ -54,7 +54,7 @@ <h2>Exit ticket</h2> <div class="lines"><div></div><div></div><div></div><div></div></div> <div class="foot">OverBoard · page 2 / 2 · Your ink saves in this browser only. Nothing is uploaded.</div> </div> -<!-- ===== INK OVERLAY START (v35) ===== +<!-- ===== INK OVERLAY START (v36) ===== Smartboard writing layer. Paste this whole block just before </body> in any .html file. SMART BOARD: pen tip writes. Palm, fist or side of hand erases (the board reports it as an eraser). Finger (and the round end of the SMART pen) scrolls the page; the "Finger" button switches it to drawing if you lose the pen. @@ -1637,6 +1637,7 @@ <h2>Exit ticket</h2> "confirm_remove_pdf": ["Remove {list} and its ink from this PDF?\n\nUndo can't bring it back.", "¿Quitar {list} y su tinta de este PDF?\n\nDeshacer no puede recuperarla.", "要从此 PDF 中删除 {list} 及其笔迹吗?\n\n撤销无法恢复。", "Alisin ang {list} at ang tinta nito sa PDF na ito?\n\nHindi na ito maibabalik ng Undo.", "Xóa {list} và nét mực của nó khỏi PDF này?\n\nHoàn tác không lấy lại được.", "Retirer {list} et son encre de ce PDF ?\n\nAnnuler ne pourra pas le faire revenir.", "Remover {list} e a tinta dele deste PDF?\n\nDesfazer não traz de volta.", "هل تزيل {list} وحبره من ملف PDF هذا؟\n\nلا يمكن للتراجع إعادته."], "explain_pick": ["Ink already saves in this browser. This also saves it INSIDE the file. In the window that opens (it says \"Open\"), pick: {f}", "La tinta ya se guarda en este navegador. Esto también la guarda DENTRO del archivo. En la ventana que se abre (dice \"Abrir\"), elige: {f}", "笔迹已经保存在此浏览器中。这会同时把它保存到文件内部。在打开的窗口中(显示“打开”),选择:{f}", "Nase-save na ang tinta sa browser na ito. Sine-save din nito ang tinta SA LOOB ng file. Sa window na lalabas (nakasulat ay \"Open\"), piliin ang: {f}", "Nét mực đã được lưu trong trình duyệt này. Lệnh này lưu thêm vào BÊN TRONG tệp. Trong cửa sổ hiện ra (ghi \"Mở\"), hãy chọn: {f}", "L'encre est déjà enregistrée dans ce navigateur. Ceci l'enregistre aussi DANS le fichier. Dans la fenêtre qui s'ouvre (elle indique « Ouvrir »), choisissez : {f}", "A tinta já é salva neste navegador. Isto também a salva DENTRO do arquivo. Na janela que abrir (diz \"Abrir\"), escolha: {f}", "الحبر محفوظ بالفعل في هذا المتصفح. هذا يحفظه أيضًا داخل الملف. في النافذة التي تظهر (مكتوب فيها \"فتح\")، اختر: {f}"], "this_worksheet": ["this worksheet", "esta hoja", "此练习页", "worksheet na ito", "bài tập này", "ce document", "esta folha", "ورقة العمل هذه"], + "tap_again_allow": ["Tap again to allow", "Toca otra vez para permitir", "再点一次以允许", "I-tap ulit para payagan", "Chạm lần nữa để cho phép", "Touchez encore pour autoriser", "Toque de novo para permitir", "اضغط مرة أخرى للسماح"], "tap_again_pick": ["Tap again to pick file", "Toca otra vez para elegir", "再点一次选择文件", "I-tap ulit para pumili", "Chạm lần nữa để chọn", "Touchez encore pour choisir", "Toque de novo para escolher", "اضغط مرة أخرى لاختيار الملف"], "st_loaded_file": ["{p} · ink loaded from file", "{p} · tinta cargada del archivo", "{p} · 已从文件载入笔迹", "{p} · na-load ang tinta mula sa file", "{p} · đã mở nét mực từ tệp", "{p} · encre chargée depuis le fichier", "{p} · tinta carregada do arquivo", "{p} · تم تحميل الحبر من الملف"], "st_restored": ["{p} · ink restored from this browser", "{p} · tinta recuperada de este navegador", "{p} · 已从此浏览器恢复笔迹", "{p} · naibalik ang tinta mula sa browser na ito", "{p} · đã khôi phục nét mực từ trình duyệt này", "{p} · encre restaurée depuis ce navigateur", "{p} · tinta recuperada deste navegador", "{p} · تمت استعادة الحبر من هذا المتصفح"], @@ -1992,9 +1993,13 @@ <h2>Exit ticket</h2> var o = parse(s); if (!o || (o.t || 0) <= t) { try { localStorage.removeItem(KEY); } catch(e){} } } + /* Ink written into a file carries w: when it was written. Ink copied into another board copy (Save ink into file, + then a different file) can have been drawn before that copy's own ink in this browser, yet it is the newer copy. */ + var shownT = 0; // how new the ink on screen is, to compare with the browser's copy function load(){ var emb = dataEl ? parse(dataEl.textContent) : null, loc = lsCopy = parse(getLS(KEY)), src = ''; - if (loc && (!emb || (loc.t || 0) > (emb.t || 0))) { D = loc; src = 'browser'; } // newest copy wins; a tie goes to the file + var et = emb ? Math.max(emb.t || 0, emb.w || 0) : 0; + if (loc && (!emb || (loc.t || 0) > et)) { D = loc; src = 'browser'; } // newest copy wins; a tie goes to the file else if (emb) { D = emb; src = 'file'; } else { D = fresh(); @@ -2004,6 +2009,8 @@ <h2>Exit ticket</h2> } catch(e){} } if (!Object.keys(D.periods).length) D = fresh(); + shownT = src === 'file' ? et : D.t || 0; + delete D.w; shortNames(); if (!D.periods[D.active]) D.active = Object.keys(D.periods)[0]; strokes = D.periods[D.active]; @@ -2012,7 +2019,7 @@ <h2>Exit ticket</h2> function loadDb(){ // then the IndexedDB copy, if it is newer than what's on screen return inkTx('readonly', function(st){ return st.get(KEY); }).then(function(r){ var got = r.ok && r.val && r.val.periods && typeof r.val.periods === 'object' ? r.val : null, src = ''; - if (got && (got.t || 0) > (D.t || 0)) { + if (got && (got.t || 0) > shownT) { D = got; shortNames(); if (!Object.keys(D.periods).length) D = fresh(); if (!D.periods[D.active]) D.active = Object.keys(D.periods)[0]; @@ -2275,6 +2282,9 @@ <h2>Exit ticket</h2> async function linkFile(){ var hs = await window.showOpenFilePicker({ types: [{ description: 'This worksheet (.html)', accept: { 'text/html': ['.html', '.htm'] } }] }); var h = hs[0], base = decodeURIComponent(location.pathname.split('/').pop()); + // ask to write right away, while the tap that opened the file window still counts: in Chrome, our question + // below uses it up, and then the browser refuses without asking + try { if ((await h.queryPermission({ mode: 'readwrite' })) !== 'granted') await h.requestPermission({ mode: 'readwrite' }); } catch(e){} if (base && h.name !== base && !confirm(tr('confirm_other_file', { a: base, b: h.name }))) return false; var txt = await (await h.getFile()).text(); if (injectInk(txt, '{}') === null) { setStatus(tr('st_no_ink_block')); return false; } @@ -2287,7 +2297,7 @@ <h2>Exit ticket</h2> try { if (!(await hasPermission(ask))) { setStatus(tr('st_tap_allow', { p: D.active })); return false; } var txt = await (await fileHandle.getFile()).text(); - var out = injectInk(txt, JSON.stringify(D)); + var out = injectInk(txt, JSON.stringify(Object.assign({}, D, { w: Date.now() }))); // w: when it was written (see load) if (out === null) { setStatus(tr('st_linked_no_block')); return false; } var w = await fileHandle.createWritable(); try { await w.write(out); await w.close(); } @@ -2316,8 +2326,18 @@ <h2>Exit ticket</h2> var ok = await saveFile(forcePick); if (ok === null) return; if (ok) flashBtn(btn, tr(wasLinked ? 'already_saved' : 'saved_to_file'), 'good'); + else if (fileHandle && !(await hasPermission(false))) askAgain(btn); else flashBtn(btn, tr('not_saved'), 'warn'); } + /* The browser wouldn't ask for permission to write (it only asks right after a tap). Keep the menu open and + say so on the button: the next tap there asks. */ + function askAgain(btn){ + if (fileMenu.el.hidden) fileMenu.show(true); + clearTimeout(btn._flash); if (!btn.classList.contains('warn')) btn._label = btn.textContent; + btn.classList.remove('good'); btn.classList.add('warn'); btn.textContent = tr('tap_again_allow'); + btn._flash = setTimeout(function(){ btn.classList.remove('warn'); btn.textContent = btn._label; }, 10000); + setStatus(tr('st_file_linked_again')); + } /* ---------- toolbar ---------- */ function isPreset(c){ return !!bar.querySelector('[data-color="' + c + '"]'); } diff --git a/overlay/ink_overlay.html b/overlay/ink_overlay.html index ab4693c..4fd01e8 100644 --- a/overlay/ink_overlay.html +++ b/overlay/ink_overlay.html @@ -1,4 +1,4 @@ -<!-- ===== INK OVERLAY START (v35) ===== +<!-- ===== INK OVERLAY START (v36) ===== Smartboard writing layer. Paste this whole block just before </body> in any .html file. SMART BOARD: pen tip writes. Palm, fist or side of hand erases (the board reports it as an eraser). Finger (and the round end of the SMART pen) scrolls the page; the "Finger" button switches it to drawing if you lose the pen. @@ -1581,6 +1581,7 @@ "confirm_remove_pdf": ["Remove {list} and its ink from this PDF?\n\nUndo can't bring it back.", "¿Quitar {list} y su tinta de este PDF?\n\nDeshacer no puede recuperarla.", "要从此 PDF 中删除 {list} 及其笔迹吗?\n\n撤销无法恢复。", "Alisin ang {list} at ang tinta nito sa PDF na ito?\n\nHindi na ito maibabalik ng Undo.", "Xóa {list} và nét mực của nó khỏi PDF này?\n\nHoàn tác không lấy lại được.", "Retirer {list} et son encre de ce PDF ?\n\nAnnuler ne pourra pas le faire revenir.", "Remover {list} e a tinta dele deste PDF?\n\nDesfazer não traz de volta.", "هل تزيل {list} وحبره من ملف PDF هذا؟\n\nلا يمكن للتراجع إعادته."], "explain_pick": ["Ink already saves in this browser. This also saves it INSIDE the file. In the window that opens (it says \"Open\"), pick: {f}", "La tinta ya se guarda en este navegador. Esto también la guarda DENTRO del archivo. En la ventana que se abre (dice \"Abrir\"), elige: {f}", "笔迹已经保存在此浏览器中。这会同时把它保存到文件内部。在打开的窗口中(显示“打开”),选择:{f}", "Nase-save na ang tinta sa browser na ito. Sine-save din nito ang tinta SA LOOB ng file. Sa window na lalabas (nakasulat ay \"Open\"), piliin ang: {f}", "Nét mực đã được lưu trong trình duyệt này. Lệnh này lưu thêm vào BÊN TRONG tệp. Trong cửa sổ hiện ra (ghi \"Mở\"), hãy chọn: {f}", "L'encre est déjà enregistrée dans ce navigateur. Ceci l'enregistre aussi DANS le fichier. Dans la fenêtre qui s'ouvre (elle indique « Ouvrir »), choisissez : {f}", "A tinta já é salva neste navegador. Isto também a salva DENTRO do arquivo. Na janela que abrir (diz \"Abrir\"), escolha: {f}", "الحبر محفوظ بالفعل في هذا المتصفح. هذا يحفظه أيضًا داخل الملف. في النافذة التي تظهر (مكتوب فيها \"فتح\")، اختر: {f}"], "this_worksheet": ["this worksheet", "esta hoja", "此练习页", "worksheet na ito", "bài tập này", "ce document", "esta folha", "ورقة العمل هذه"], + "tap_again_allow": ["Tap again to allow", "Toca otra vez para permitir", "再点一次以允许", "I-tap ulit para payagan", "Chạm lần nữa để cho phép", "Touchez encore pour autoriser", "Toque de novo para permitir", "اضغط مرة أخرى للسماح"], "tap_again_pick": ["Tap again to pick file", "Toca otra vez para elegir", "再点一次选择文件", "I-tap ulit para pumili", "Chạm lần nữa để chọn", "Touchez encore pour choisir", "Toque de novo para escolher", "اضغط مرة أخرى لاختيار الملف"], "st_loaded_file": ["{p} · ink loaded from file", "{p} · tinta cargada del archivo", "{p} · 已从文件载入笔迹", "{p} · na-load ang tinta mula sa file", "{p} · đã mở nét mực từ tệp", "{p} · encre chargée depuis le fichier", "{p} · tinta carregada do arquivo", "{p} · تم تحميل الحبر من الملف"], "st_restored": ["{p} · ink restored from this browser", "{p} · tinta recuperada de este navegador", "{p} · 已从此浏览器恢复笔迹", "{p} · naibalik ang tinta mula sa browser na ito", "{p} · đã khôi phục nét mực từ trình duyệt này", "{p} · encre restaurée depuis ce navigateur", "{p} · tinta recuperada deste navegador", "{p} · تمت استعادة الحبر من هذا المتصفح"], @@ -1936,9 +1937,13 @@ var o = parse(s); if (!o || (o.t || 0) <= t) { try { localStorage.removeItem(KEY); } catch(e){} } } + /* Ink written into a file carries w: when it was written. Ink copied into another board copy (Save ink into file, + then a different file) can have been drawn before that copy's own ink in this browser, yet it is the newer copy. */ + var shownT = 0; // how new the ink on screen is, to compare with the browser's copy function load(){ var emb = dataEl ? parse(dataEl.textContent) : null, loc = lsCopy = parse(getLS(KEY)), src = ''; - if (loc && (!emb || (loc.t || 0) > (emb.t || 0))) { D = loc; src = 'browser'; } // newest copy wins; a tie goes to the file + var et = emb ? Math.max(emb.t || 0, emb.w || 0) : 0; + if (loc && (!emb || (loc.t || 0) > et)) { D = loc; src = 'browser'; } // newest copy wins; a tie goes to the file else if (emb) { D = emb; src = 'file'; } else { D = fresh(); @@ -1948,6 +1953,8 @@ } catch(e){} } if (!Object.keys(D.periods).length) D = fresh(); + shownT = src === 'file' ? et : D.t || 0; + delete D.w; shortNames(); if (!D.periods[D.active]) D.active = Object.keys(D.periods)[0]; strokes = D.periods[D.active]; @@ -1956,7 +1963,7 @@ function loadDb(){ // then the IndexedDB copy, if it is newer than what's on screen return inkTx('readonly', function(st){ return st.get(KEY); }).then(function(r){ var got = r.ok && r.val && r.val.periods && typeof r.val.periods === 'object' ? r.val : null, src = ''; - if (got && (got.t || 0) > (D.t || 0)) { + if (got && (got.t || 0) > shownT) { D = got; shortNames(); if (!Object.keys(D.periods).length) D = fresh(); if (!D.periods[D.active]) D.active = Object.keys(D.periods)[0]; @@ -2219,6 +2226,9 @@ async function linkFile(){ var hs = await window.showOpenFilePicker({ types: [{ description: 'This worksheet (.html)', accept: { 'text/html': ['.html', '.htm'] } }] }); var h = hs[0], base = decodeURIComponent(location.pathname.split('/').pop()); + // ask to write right away, while the tap that opened the file window still counts: in Chrome, our question + // below uses it up, and then the browser refuses without asking + try { if ((await h.queryPermission({ mode: 'readwrite' })) !== 'granted') await h.requestPermission({ mode: 'readwrite' }); } catch(e){} if (base && h.name !== base && !confirm(tr('confirm_other_file', { a: base, b: h.name }))) return false; var txt = await (await h.getFile()).text(); if (injectInk(txt, '{}') === null) { setStatus(tr('st_no_ink_block')); return false; } @@ -2231,7 +2241,7 @@ try { if (!(await hasPermission(ask))) { setStatus(tr('st_tap_allow', { p: D.active })); return false; } var txt = await (await fileHandle.getFile()).text(); - var out = injectInk(txt, JSON.stringify(D)); + var out = injectInk(txt, JSON.stringify(Object.assign({}, D, { w: Date.now() }))); // w: when it was written (see load) if (out === null) { setStatus(tr('st_linked_no_block')); return false; } var w = await fileHandle.createWritable(); try { await w.write(out); await w.close(); } @@ -2260,8 +2270,18 @@ var ok = await saveFile(forcePick); if (ok === null) return; if (ok) flashBtn(btn, tr(wasLinked ? 'already_saved' : 'saved_to_file'), 'good'); + else if (fileHandle && !(await hasPermission(false))) askAgain(btn); else flashBtn(btn, tr('not_saved'), 'warn'); } + /* The browser wouldn't ask for permission to write (it only asks right after a tap). Keep the menu open and + say so on the button: the next tap there asks. */ + function askAgain(btn){ + if (fileMenu.el.hidden) fileMenu.show(true); + clearTimeout(btn._flash); if (!btn.classList.contains('warn')) btn._label = btn.textContent; + btn.classList.remove('good'); btn.classList.add('warn'); btn.textContent = tr('tap_again_allow'); + btn._flash = setTimeout(function(){ btn.classList.remove('warn'); btn.textContent = btn._label; }, 10000); + setStatus(tr('st_file_linked_again')); + } /* ---------- toolbar ---------- */ function isPreset(c){ return !!bar.querySelector('[data-color="' + c + '"]'); } diff --git a/shared/strings.js b/shared/strings.js index 98041ff..b66a465 100644 --- a/shared/strings.js +++ b/shared/strings.js @@ -236,6 +236,7 @@ function obStrings(){ "confirm_remove_pdf": ["Remove {list} and its ink from this PDF?\n\nUndo can't bring it back.", "¿Quitar {list} y su tinta de este PDF?\n\nDeshacer no puede recuperarla.", "要从此 PDF 中删除 {list} 及其笔迹吗?\n\n撤销无法恢复。", "Alisin ang {list} at ang tinta nito sa PDF na ito?\n\nHindi na ito maibabalik ng Undo.", "Xóa {list} và nét mực của nó khỏi PDF này?\n\nHoàn tác không lấy lại được.", "Retirer {list} et son encre de ce PDF ?\n\nAnnuler ne pourra pas le faire revenir.", "Remover {list} e a tinta dele deste PDF?\n\nDesfazer não traz de volta.", "هل تزيل {list} وحبره من ملف PDF هذا؟\n\nلا يمكن للتراجع إعادته."], "explain_pick": ["Ink already saves in this browser. This also saves it INSIDE the file. In the window that opens (it says \"Open\"), pick: {f}", "La tinta ya se guarda en este navegador. Esto también la guarda DENTRO del archivo. En la ventana que se abre (dice \"Abrir\"), elige: {f}", "笔迹已经保存在此浏览器中。这会同时把它保存到文件内部。在打开的窗口中(显示“打开”),选择:{f}", "Nase-save na ang tinta sa browser na ito. Sine-save din nito ang tinta SA LOOB ng file. Sa window na lalabas (nakasulat ay \"Open\"), piliin ang: {f}", "Nét mực đã được lưu trong trình duyệt này. Lệnh này lưu thêm vào BÊN TRONG tệp. Trong cửa sổ hiện ra (ghi \"Mở\"), hãy chọn: {f}", "L'encre est déjà enregistrée dans ce navigateur. Ceci l'enregistre aussi DANS le fichier. Dans la fenêtre qui s'ouvre (elle indique « Ouvrir »), choisissez : {f}", "A tinta já é salva neste navegador. Isto também a salva DENTRO do arquivo. Na janela que abrir (diz \"Abrir\"), escolha: {f}", "الحبر محفوظ بالفعل في هذا المتصفح. هذا يحفظه أيضًا داخل الملف. في النافذة التي تظهر (مكتوب فيها \"فتح\")، اختر: {f}"], "this_worksheet": ["this worksheet", "esta hoja", "此练习页", "worksheet na ito", "bài tập này", "ce document", "esta folha", "ورقة العمل هذه"], + "tap_again_allow": ["Tap again to allow", "Toca otra vez para permitir", "再点一次以允许", "I-tap ulit para payagan", "Chạm lần nữa để cho phép", "Touchez encore pour autoriser", "Toque de novo para permitir", "اضغط مرة أخرى للسماح"], "tap_again_pick": ["Tap again to pick file", "Toca otra vez para elegir", "再点一次选择文件", "I-tap ulit para pumili", "Chạm lần nữa để chọn", "Touchez encore pour choisir", "Toque de novo para escolher", "اضغط مرة أخرى لاختيار الملف"], "st_loaded_file": ["{p} · ink loaded from file", "{p} · tinta cargada del archivo", "{p} · 已从文件载入笔迹", "{p} · na-load ang tinta mula sa file", "{p} · đã mở nét mực từ tệp", "{p} · encre chargée depuis le fichier", "{p} · tinta carregada do arquivo", "{p} · تم تحميل الحبر من الملف"], "st_restored": ["{p} · ink restored from this browser", "{p} · tinta recuperada de este navegador", "{p} · 已从此浏览器恢复笔迹", "{p} · naibalik ang tinta mula sa browser na ito", "{p} · đã khôi phục nét mực từ trình duyệt này", "{p} · encre restaurée depuis ce navigateur", "{p} · tinta recuperada deste navegador", "{p} · تمت استعادة الحبر من هذا المتصفح"], diff --git a/tests/savefile.spec.js b/tests/savefile.spec.js new file mode 100644 index 0000000..6c5891b --- /dev/null +++ b/tests/savefile.spec.js @@ -0,0 +1,108 @@ +// HTML overlay ▸ Settings & help ▸ Save ink into file (Chrome/Edge), including into a differently named board copy. +// The stand-in file handle behaves like Chrome: the browser only asks "allow writing?" right after a tap, and a +// question box (confirm) in between uses that tap up. Without the tap, it refuses without asking. +const fs = require('fs'); +const path = require('path'); +const { test, expect } = require('@playwright/test'); +const { overlayReady } = require('./support/overlay'); + +const ROOT = path.join(__dirname, '..'); +const DEMO = fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8'); +const url = (file) => '/' + path.relative(ROOT, file).split(path.sep).map(encodeURIComponent).join('/'); +const DATA_RE = /(<script[^>]*id="ink-data"[^>]*>)([\s\S]*?)(<\/script>)/; +const inkIn = (html) => JSON.parse(DATA_RE.exec(html)[2]); +const withInk = (html, D) => html.replace(DATA_RE, (m, a, b, c) => a + JSON.stringify(D) + c); + +async function fakePicker(page, name, text, refuse = 0) { + await page.addInitScript(([name, text, refuse]) => { + window.__file = text; window.__asked = 0; + let perm = 'prompt', tapped = false, refusals = refuse; + document.addEventListener('pointerdown', () => { tapped = true; }, true); // a tap lets the browser ask once + const ask = window.confirm.bind(window); + window.confirm = (m) => { tapped = false; return ask(m); }; // a question box uses the tap up + const h = { + kind: 'file', name, + async getFile() { return new File([window.__file], name, { type: 'text/html' }); }, + async createWritable() { + if (perm !== 'granted') throw new DOMException('not allowed', 'NotAllowedError'); + const parts = []; return { async write(b) { parts.push(b); }, async close() { window.__file = parts.join(''); }, async abort() {} }; + }, + async queryPermission() { return perm; }, + async requestPermission() { + if (!tapped || refusals-- > 0) { tapped = false; throw new DOMException('User activation is required to request permissions.', 'SecurityError'); } + tapped = false; window.__asked++; perm = 'granted'; return perm; + }, + }; + window.showOpenFilePicker = async () => [h]; + }, [name, text, refuse]); +} +async function draw(page, y = 300) { + await page.evaluate(async (y) => { + const c = document.getElementById('ink-canvas'), b = c.getBoundingClientRect(); + const ev = (t, x) => c.dispatchEvent(new PointerEvent(t, { bubbles: true, cancelable: true, pointerType: 'pen', pointerId: 3, buttons: t === 'pointerup' ? 0 : 1, button: 0, clientX: x, clientY: b.top + y })); + ev('pointerdown', b.left + 700); + for (let x = 710; x <= 900; x += 10) { await new Promise((r) => setTimeout(r, 4)); ev('pointermove', b.left + x); } + await new Promise((r) => setTimeout(r, 20)); ev('pointerup', b.left + 900); + }, y); +} +const saveBtn = (page) => page.locator('#ob-filemenu [data-act="savefile"]'); +async function saveTap(page) { + if (await page.locator('#ob-filemenu').isHidden()) await page.click('#ink-bar [data-act="file"]'); + await saveBtn(page).click(); +} +async function open(page, file) { + await page.setViewportSize({ width: 1400, height: 900 }); + await page.goto(url(file)); await overlayReady(page); +} + +test('save ink into a differently named board copy: the browser asks before our "different file" question', async ({ page }, info) => { + const inked = info.outputPath('pet_board_inked.html'); + fs.writeFileSync(inked, DEMO); + await fakePicker(page, 'pet_board_clean.html', DEMO); + await open(page, inked); + await draw(page); + await page.click('#ink-bar [data-period="P2"]'); + await draw(page, 400); + let asked = ''; + page.on('dialog', (d) => { asked = d.message(); d.accept(); }); + await saveTap(page); // the first tap explains + await saveTap(page); // the second opens the file window + await expect(saveBtn(page)).toHaveText('Saved to file!'); + expect(asked).toContain('pet_board_clean.html'); + expect(await page.evaluate(() => window.__asked)).toBe(1); + const ink = inkIn(await page.evaluate(() => window.__file)); + expect([ink.periods.P1.length, ink.periods.P2.length]).toEqual([1, 1]); // every period's ink + expect(ink.w).toBeGreaterThanOrEqual(ink.t); +}); + +test('when the browser still won\'t ask, the button says to tap again, and the next tap saves', async ({ page }, info) => { + const inked = info.outputPath('pet_board.html'); + fs.writeFileSync(inked, DEMO); + await fakePicker(page, 'pet_board.html', DEMO, 1); // refuses the first time + await open(page, inked); + await draw(page); + await saveTap(page); + await saveTap(page); + await expect(saveBtn(page)).toHaveText('Tap again to allow'); + await expect(page.locator('#ob-filemenu')).toBeVisible(); // still open, the button right there + await saveBtn(page).click(); + await expect(saveBtn(page)).toHaveText('Saved to file!'); + expect(inkIn(await page.evaluate(() => window.__file)).periods.P1).toHaveLength(1); +}); + +test('ink written into a board copy shows, even if this browser used that copy more recently than the ink was drawn', async ({ page }, info) => { + const clean = info.outputPath('pet_board_clean.html'); + fs.writeFileSync(clean, DEMO); + await open(page, clean); + await draw(page); // try the clean copy out, then clear it: this browser now keeps an empty, newer copy + await page.click('#ink-bar [data-act="clear-all"]'); await page.click('#ink-bar [data-act="clear-all"]'); + await page.evaluate(() => window.dispatchEvent(new Event('pagehide'))); + await page.waitForTimeout(300); + // another browser (Edge) wrote ink drawn an hour ago into this file, just now + const now = Date.now(), stroke = { c: '#111', w: 3, e: 0, p: [100, 100, 300, 100] }; + fs.writeFileSync(clean, withInk(DEMO, { v: 2, t: now - 3600e3, w: now, active: 'P1', periods: { P1: [stroke], P2: [], P3: [], P4: [] } })); + await open(page, clean); + await expect(page.locator('#ink-status')).toContainText('ink loaded from file'); + await page.click('#ink-bar [data-act="undo"]'); // the file's ink is what's on screen: Undo takes its line away + await expect(page.locator('#ink-bar [data-act="redo"]')).toBeEnabled(); +});