max / goingson
6 files changed,
+267 insertions,
-54 deletions
| @@ -1387,7 +1387,7 @@ | |||
| 1387 | 1387 | "libc", | |
| 1388 | 1388 | "option-ext", | |
| 1389 | 1389 | "redox_users", | |
| 1390 | - | "windows-sys 0.61.2", | |
| 1390 | + | "windows-sys 0.60.2", | |
| 1391 | 1391 | ] | |
| 1392 | 1392 | ||
| 1393 | 1393 | [[package]] | |
| @@ -1628,7 +1628,7 @@ | |||
| 1628 | 1628 | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" | |
| 1629 | 1629 | dependencies = [ | |
| 1630 | 1630 | "libc", | |
| 1631 | - | "windows-sys 0.61.2", | |
| 1631 | + | "windows-sys 0.60.2", | |
| 1632 | 1632 | ] | |
| 1633 | 1633 | ||
| 1634 | 1634 | [[package]] | |
| @@ -3639,7 +3639,7 @@ | |||
| 3639 | 3639 | "png 0.18.1", | |
| 3640 | 3640 | "serde", | |
| 3641 | 3641 | "thiserror 2.0.20", | |
| 3642 | - | "windows-sys 0.61.2", | |
| 3642 | + | "windows-sys 0.60.2", | |
| 3643 | 3643 | ] | |
| 3644 | 3644 | ||
| 3645 | 3645 | [[package]] | |
| @@ -3772,7 +3772,7 @@ | |||
| 3772 | 3772 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3773 | 3773 | checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" | |
| 3774 | 3774 | dependencies = [ | |
| 3775 | - | "windows-sys 0.61.2", | |
| 3775 | + | "windows-sys 0.60.2", | |
| 3776 | 3776 | ] | |
| 3777 | 3777 | ||
| 3778 | 3778 | [[package]] | |
| @@ -4706,7 +4706,7 @@ | |||
| 4706 | 4706 | ||
| 4707 | 4707 | [[package]] | |
| 4708 | 4708 | name = "quasi-http" | |
| 4709 | - | version = "0.53.0" | |
| 4709 | + | version = "0.54.0" | |
| 4710 | 4710 | dependencies = [ | |
| 4711 | 4711 | "form_urlencoded", | |
| 4712 | 4712 | "http", | |
| @@ -4715,7 +4715,7 @@ | |||
| 4715 | 4715 | ||
| 4716 | 4716 | [[package]] | |
| 4717 | 4717 | name = "quasi-notifs" | |
| 4718 | - | version = "0.53.0" | |
| 4718 | + | version = "0.54.0" | |
| 4719 | 4719 | dependencies = [ | |
| 4720 | 4720 | "quasi-router", | |
| 4721 | 4721 | "synckit-config", | |
| @@ -4723,14 +4723,14 @@ | |||
| 4723 | 4723 | ||
| 4724 | 4724 | [[package]] | |
| 4725 | 4725 | name = "quasi-router" | |
| 4726 | - | version = "0.53.0" | |
| 4726 | + | version = "0.54.0" | |
| 4727 | 4727 | dependencies = [ | |
| 4728 | 4728 | "makeover-layout", | |
| 4729 | 4729 | ] | |
| 4730 | 4730 | ||
| 4731 | 4731 | [[package]] | |
| 4732 | 4732 | name = "quasi-tauri" | |
| 4733 | - | version = "0.53.0" | |
| 4733 | + | version = "0.54.0" | |
| 4734 | 4734 | dependencies = [ | |
| 4735 | 4735 | "http", | |
| 4736 | 4736 | "quasi-http", | |
| @@ -4756,7 +4756,7 @@ | |||
| 4756 | 4756 | ||
| 4757 | 4757 | [[package]] | |
| 4758 | 4758 | name = "quasi-webview" | |
| 4759 | - | version = "0.53.0" | |
| 4759 | + | version = "0.54.0" | |
| 4760 | 4760 | dependencies = [ | |
| 4761 | 4761 | "docengine", | |
| 4762 | 4762 | "makeover-layout", | |
| @@ -5195,7 +5195,7 @@ | |||
| 5195 | 5195 | "errno", | |
| 5196 | 5196 | "libc", | |
| 5197 | 5197 | "linux-raw-sys", | |
| 5198 | - | "windows-sys 0.61.2", | |
| 5198 | + | "windows-sys 0.60.2", | |
| 5199 | 5199 | ] | |
| 5200 | 5200 | ||
| 5201 | 5201 | [[package]] | |
| @@ -5252,7 +5252,7 @@ | |||
| 5252 | 5252 | "security-framework", | |
| 5253 | 5253 | "security-framework-sys", | |
| 5254 | 5254 | "webpki-root-certs", | |
| 5255 | - | "windows-sys 0.61.2", | |
| 5255 | + | "windows-sys 0.60.2", | |
| 5256 | 5256 | ] | |
| 5257 | 5257 | ||
| 5258 | 5258 | [[package]] | |
| @@ -5792,7 +5792,7 @@ | |||
| 5792 | 5792 | checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" | |
| 5793 | 5793 | dependencies = [ | |
| 5794 | 5794 | "libc", | |
| 5795 | - | "windows-sys 0.61.2", | |
| 5795 | + | "windows-sys 0.60.2", | |
| 5796 | 5796 | ] | |
| 5797 | 5797 | ||
| 5798 | 5798 | [[package]] | |
| @@ -6567,7 +6567,7 @@ | |||
| 6567 | 6567 | "getrandom 0.4.3", | |
| 6568 | 6568 | "once_cell", | |
| 6569 | 6569 | "rustix", | |
| 6570 | - | "windows-sys 0.61.2", | |
| 6570 | + | "windows-sys 0.60.2", | |
| 6571 | 6571 | ] | |
| 6572 | 6572 | ||
| 6573 | 6573 | [[package]] | |
| @@ -6589,7 +6589,7 @@ | |||
| 6589 | 6589 | "parking_lot", | |
| 6590 | 6590 | "rustix", | |
| 6591 | 6591 | "signal-hook", | |
| 6592 | - | "windows-sys 0.61.2", | |
| 6592 | + | "windows-sys 0.60.2", | |
| 6593 | 6593 | ] | |
| 6594 | 6594 | ||
| 6595 | 6595 | [[package]] | |
| @@ -7071,7 +7071,7 @@ | |||
| 7071 | 7071 | "png 0.18.1", | |
| 7072 | 7072 | "serde", | |
| 7073 | 7073 | "thiserror 2.0.20", | |
| 7074 | - | "windows-sys 0.61.2", | |
| 7074 | + | "windows-sys 0.60.2", | |
| 7075 | 7075 | ] | |
| 7076 | 7076 | ||
| 7077 | 7077 | [[package]] | |
| @@ -7106,7 +7106,7 @@ | |||
| 7106 | 7106 | dependencies = [ | |
| 7107 | 7107 | "memoffset", | |
| 7108 | 7108 | "tempfile", | |
| 7109 | - | "windows-sys 0.61.2", | |
| 7109 | + | "windows-sys 0.60.2", | |
| 7110 | 7110 | ] | |
| 7111 | 7111 | ||
| 7112 | 7112 | [[package]] | |
| @@ -7636,7 +7636,7 @@ | |||
| 7636 | 7636 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 7637 | 7637 | checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" | |
| 7638 | 7638 | dependencies = [ | |
| 7639 | - | "windows-sys 0.61.2", | |
| 7639 | + | "windows-sys 0.60.2", | |
| 7640 | 7640 | ] | |
| 7641 | 7641 | ||
| 7642 | 7642 | [[package]] | |
| @@ -8494,21 +8494,21 @@ | |||
| 8494 | 8494 | ] | |
| 8495 | 8495 | ||
| 8496 | 8496 | [[patch.unused]] | |
| 8497 | - | name = "quasi-axum" | |
| 8498 | - | version = "0.53.0" | |
| 8499 | - | ||
| 8500 | - | [[patch.unused]] | |
| 8501 | - | name = "quasi-basics" | |
| 8502 | - | version = "0.53.0" | |
| 8503 | - | ||
| 8504 | - | [[patch.unused]] | |
| 8505 | - | name = "quasi-immediate" | |
| 8506 | - | version = "0.53.0" | |
| 8507 | - | ||
| 8508 | - | [[patch.unused]] | |
| 8509 | - | name = "quasi-store" | |
| 8497 | + | name = "ops-status" | |
| 8510 | 8498 | version = "0.1.0" | |
| 8511 | 8499 | ||
| 8512 | 8500 | [[patch.unused]] | |
| 8513 | - | name = "ops-status" | |
| 8501 | + | name = "quasi-axum" | |
| 8502 | + | version = "0.54.0" | |
| 8503 | + | ||
| 8504 | + | [[patch.unused]] | |
| 8505 | + | name = "quasi-basics" | |
| 8506 | + | version = "0.54.0" | |
| 8507 | + | ||
| 8508 | + | [[patch.unused]] | |
| 8509 | + | name = "quasi-immediate" | |
| 8510 | + | version = "0.54.0" | |
| 8511 | + | ||
| 8512 | + | [[patch.unused]] | |
| 8513 | + | name = "quasi-store" | |
| 8514 | 8514 | version = "0.1.0" |
| @@ -36,7 +36,7 @@ | |||
| 36 | 36 | makeover-geometry = "0.7" | |
| 37 | 37 | # Only to write its selection and clock scripts into the frontend. Those two are | |
| 38 | 38 | # the whole of `frontend/js/` since the swap. | |
| 39 | - | quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.53" } | |
| 39 | + | quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.54" } | |
| 40 | 40 | ||
| 41 | 41 | [dependencies] | |
| 42 | 42 | goingson-core = { workspace = true } | |
| @@ -54,10 +54,10 @@ | |||
| 54 | 54 | # dependency's source even with the feature off -- so a public clone could not | |
| 55 | 55 | # build at all against a URL it could not reach. quasi went public that day and | |
| 56 | 56 | # the URL moved here with it. | |
| 57 | - | quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.53" } | |
| 58 | - | quasi-http = { git = "https://makenot.work/git/max/quasi.git", version = "0.53" } | |
| 59 | - | quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.53" } | |
| 60 | - | quasi-tauri = { git = "https://makenot.work/git/max/quasi.git", version = "0.53" } | |
| 57 | + | quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.54" } | |
| 58 | + | quasi-http = { git = "https://makenot.work/git/max/quasi.git", version = "0.54" } | |
| 59 | + | quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.54" } | |
| 60 | + | quasi-tauri = { git = "https://makenot.work/git/max/quasi.git", version = "0.54" } | |
| 61 | 61 | ||
| 62 | 62 | # The notification declaration, and it is NOT behind the `quasi` feature: the | |
| 63 | 63 | # watcher is desktop app behaviour rather than a described screen, and the | |
| @@ -74,7 +74,7 @@ | |||
| 74 | 74 | # before it has ported its screens cannot take it. `notifications::Notifier` is | |
| 75 | 75 | # the eight lines meanwhile, and it goes when the flip makes quasi-router | |
| 76 | 76 | # unconditional here. | |
| 77 | - | quasi-notifs = { git = "https://makenot.work/git/max/quasi.git", version = "0.53", features = ["synckit", "describe"] } | |
| 77 | + | quasi-notifs = { git = "https://makenot.work/git/max/quasi.git", version = "0.54", features = ["synckit", "describe"] } | |
| 78 | 78 | ||
| 79 | 79 | # Tauri | |
| 80 | 80 | tauri = { workspace = true, features = ["image-png", "devtools"] } |
| @@ -471,7 +471,7 @@ | |||
| 471 | 471 | /// Every file that can carry a class name. | |
| 472 | 472 | fn markup_files(frontend: &Path) -> Vec<PathBuf> { | |
| 473 | 473 | // `index.html` and `compose.html` were the first two entries until the | |
| 474 | - | // 2026-08-22 swap deleted them. What is left under `js/` is two generated | |
| 474 | + | // 2026-08-22 swap deleted them. What is left under `js/` is the generated | |
| 475 | 475 | // scripts, and they are still read: `quasi-selection.js` names the classes | |
| 476 | 476 | // quasi-webview emits, so a class it reaches for that the stylesheet does | |
| 477 | 477 | // not define is the same defect this check was written to catch. | |
| @@ -565,21 +565,25 @@ | |||
| 565 | 565 | fs::write(frontend.join("js/tables.generated.js"), table_columns_js()) | |
| 566 | 566 | .expect("write js/tables.generated.js"); | |
| 567 | 567 | ||
| 568 | - | // quasi-webview's selection script, written out rather than copied in, so | |
| 569 | - | // it cannot go stale against the markup that feeds it: it reads two hooks | |
| 570 | - | // that crate emits, and a fossilised copy here is a bulk bar that silently | |
| 571 | - | // stops counting. | |
| 572 | - | fs::write( | |
| 573 | - | frontend.join("js/quasi-selection.js"), | |
| 574 | - | quasi_webview::SELECTION_JS, | |
| 575 | - | ) | |
| 576 | - | .expect("write js/quasi-selection.js"); | |
| 577 | - | ||
| 578 | - | // The clock script, for the same reason: the running-timer band is drawn | |
| 579 | - | // from a description and ticks from this, and a stale copy is a band that | |
| 580 | - | // shows the time the screen was served. | |
| 581 | - | fs::write(frontend.join("js/quasi-clock.js"), quasi_webview::CLOCK_JS) | |
| 582 | - | .expect("write js/quasi-clock.js"); | |
| 568 | + | // The scripts quasi-webview ships, written out rather than copied in, so | |
| 569 | + | // none of them can go stale against the markup that feeds it: each reads a | |
| 570 | + | // hook that crate emits, and a fossilised copy here is a bulk bar that | |
| 571 | + | // silently stops counting, a band showing the time the screen was served, | |
| 572 | + | // an export pasted into the page, or a picker that writes nothing. | |
| 573 | + | // | |
| 574 | + | // A loop rather than four calls, because the shell links every one of these | |
| 575 | + | // by default: a script the renderer starts shipping and this file forgets | |
| 576 | + | // is a 404 per document with nothing in the log. `quasi-download.js` was | |
| 577 | + | // exactly that until 2026-08-22. | |
| 578 | + | for (name, source) in [ | |
| 579 | + | ("quasi-selection.js", quasi_webview::SELECTION_JS), | |
| 580 | + | ("quasi-clock.js", quasi_webview::CLOCK_JS), | |
| 581 | + | ("quasi-download.js", quasi_webview::DOWNLOAD_JS), | |
| 582 | + | ("quasi-fill.js", quasi_webview::FILL_JS), | |
| 583 | + | ] { | |
| 584 | + | fs::write(frontend.join("js").join(name), source) | |
| 585 | + | .unwrap_or_else(|error| panic!("write js/{name}: {error}")); | |
| 586 | + | } | |
| 583 | 587 | ||
| 584 | 588 | // The generated files above cannot drift from SizeClass. The hand-written | |
| 585 | 589 | // ones can, so they are checked rather than trusted. |
| @@ -35,13 +35,17 @@ | |||
| 35 | 35 | // descriptions run the same runtime. Out of `js/` because the | |
| 36 | 36 | // design-system lint polices that directory as our own code and a | |
| 37 | 37 | // minified library calls `confirm()` inside itself. | |
| 38 | - | // The other two are written out by `build.rs` from the renderer crate's | |
| 38 | + | // The rest are written out by `build.rs` from the renderer crate's | |
| 39 | 39 | // own consts, so they cannot fossilise against the markup they read. | |
| 40 | + | // Every one the shell links has to be here: an address the shell names | |
| 41 | + | // and this match does not is a 404 per document. | |
| 40 | 42 | "/static/htmx.min.js" => (JS, include_bytes!("../../frontend/vendor/htmx.min.js")), | |
| 41 | 43 | "/static/quasi-selection.js" => { | |
| 42 | 44 | (JS, include_bytes!("../../frontend/js/quasi-selection.js")) | |
| 43 | 45 | } | |
| 44 | 46 | "/static/quasi-clock.js" => (JS, include_bytes!("../../frontend/js/quasi-clock.js")), | |
| 47 | + | "/static/quasi-download.js" => (JS, include_bytes!("../../frontend/js/quasi-download.js")), | |
| 48 | + | "/static/quasi-fill.js" => (JS, include_bytes!("../../frontend/js/quasi-fill.js")), | |
| 45 | 49 | ||
| 46 | 50 | // `/static/../fonts/X` off typography.css, which the URL parser has | |
| 47 | 51 | // already resolved by the time it reaches here. | |
| @@ -85,6 +89,8 @@ | |||
| 85 | 89 | "/static/htmx.min.js", | |
| 86 | 90 | "/static/quasi-selection.js", | |
| 87 | 91 | "/static/quasi-clock.js", | |
| 92 | + | "/static/quasi-download.js", | |
| 93 | + | "/static/quasi-fill.js", | |
| 88 | 94 | "/fonts/QuasiMono.woff2", | |
| 89 | 95 | "/fonts/QuasiBody.woff2", | |
| 90 | 96 | "/fonts/Reglo-Bold.woff2", |
| @@ -1,0 +1,108 @@ | |||
| 1 | + | // What a browser needs before a route's answer can become a saved file. | |
| 2 | + | // | |
| 3 | + | // `67881a88`. A route answers `Outcome::File` and this host puts the bytes | |
| 4 | + | // where downloads go. The header alone is enough for a plain link -- the | |
| 5 | + | // browser navigates, sees `Content-Disposition: attachment`, and saves -- but a | |
| 6 | + | // control this renderer emits reaches its route through htmx, and htmx reads | |
| 7 | + | // the answer into an XHR. An XHR that arrives is not a navigation, so nothing | |
| 8 | + | // is saved and the file is swapped into the page as text. | |
| 9 | + | // | |
| 10 | + | // So this cancels that swap and does the saving itself: a Blob of the body, an | |
| 11 | + | // anchor clicked, the object URL revoked. It is the browser's own download | |
| 12 | + | // path, reached from script because the request was made from script. | |
| 13 | + | // | |
| 14 | + | // Nothing here knows what the file is. It reads one thing, the | |
| 15 | + | // `Content-Disposition` the http adapter wrote, which is the same header the | |
| 16 | + | // no-script path relies on -- so there is one fact on the wire rather than a | |
| 17 | + | // second one invented for this file. | |
| 18 | + | (() => { | |
| 19 | + | "use strict"; | |
| 20 | + | ||
| 21 | + | // The header the http adapter writes. RFC 6266 says a lot more than this | |
| 22 | + | // reads; the two forms below are the two it writes. | |
| 23 | + | const DISPOSITION = "content-disposition"; | |
| 24 | + | ||
| 25 | + | /** | |
| 26 | + | * The file name the header suggests, or null when it suggests none. | |
| 27 | + | * | |
| 28 | + | * `filename*` first, because it carries the real characters and the quoted | |
| 29 | + | * `filename` beside it is the ASCII fallback for agents that cannot read | |
| 30 | + | * one. Both are written for every download, so preferring the richer one is | |
| 31 | + | * always available and never a guess. | |
| 32 | + | */ | |
| 33 | + | const named = (header) => { | |
| 34 | + | const extended = /filename\*=UTF-8''([^;]+)/i.exec(header); | |
| 35 | + | if (extended) { | |
| 36 | + | try { | |
| 37 | + | return decodeURIComponent(extended[1]); | |
| 38 | + | } catch { | |
| 39 | + | // A name we cannot decode is a name we do not use. Falling | |
| 40 | + | // through to the quoted form is better than saving a file | |
| 41 | + | // called `%E2%9C`. | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | const quoted = /filename="([^"]*)"/i.exec(header); | |
| 45 | + | return quoted ? quoted[1] : null; | |
| 46 | + | }; | |
| 47 | + | ||
| 48 | + | /** Hand the bytes to the browser under this name. */ | |
| 49 | + | const save = (blob, name) => { | |
| 50 | + | const url = URL.createObjectURL(blob); | |
| 51 | + | const link = document.createElement("a"); | |
| 52 | + | link.href = url; | |
| 53 | + | link.download = name; | |
| 54 | + | // Firefox will not follow a click on an element outside the document, | |
| 55 | + | // which is the one reason this touches the DOM at all. | |
| 56 | + | document.body.append(link); | |
| 57 | + | link.click(); | |
| 58 | + | link.remove(); | |
| 59 | + | // Not revoked synchronously: the click starts the download | |
| 60 | + | // asynchronously and revoking first cancels it in some browsers. A task | |
| 61 | + | // later is after the download has taken its reference. | |
| 62 | + | setTimeout(() => URL.revokeObjectURL(url), 0); | |
| 63 | + | }; | |
| 64 | + | ||
| 65 | + | // `htmx:beforeSwap` is the last event that can still stop the body reaching | |
| 66 | + | // the page, and it carries the XHR, which is where both the header and the | |
| 67 | + | // bytes are. Anything later has already destroyed a region. | |
| 68 | + | document.addEventListener("htmx:beforeSwap", (event) => { | |
| 69 | + | const xhr = event.detail?.xhr; | |
| 70 | + | if (!xhr) { | |
| 71 | + | return; | |
| 72 | + | } | |
| 73 | + | const header = xhr.getResponseHeader(DISPOSITION); | |
| 74 | + | if (!header || !/^\s*attachment/i.test(header)) { | |
| 75 | + | return; | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // Nothing goes into the document. The screen the user pressed the | |
| 79 | + | // control on is the screen they keep, which is what the router said by | |
| 80 | + | // answering a file rather than a fragment. | |
| 81 | + | event.detail.shouldSwap = false; | |
| 82 | + | event.detail.isError = false; | |
| 83 | + | ||
| 84 | + | const type = xhr.getResponseHeader("content-type") || "application/octet-stream"; | |
| 85 | + | // THIS IS TEXT-ONLY, AND IT IS A REAL LIMIT RATHER THAN AN OVERSIGHT. | |
| 86 | + | // | |
| 87 | + | // htmx leaves `responseType` unset, so the browser has already decoded | |
| 88 | + | // the body as UTF-8 by the time this runs and `xhr.response` is a | |
| 89 | + | // string. That round-trips losslessly for the measured sites -- JSON, | |
| 90 | + | // CSV, ICS, every export in the tree -- and mangles any byte sequence | |
| 91 | + | // that is not valid UTF-8, because the replacement characters were | |
| 92 | + | // substituted before this file could see them. | |
| 93 | + | // | |
| 94 | + | // The fixes all cost more than the case is worth today. Setting | |
| 95 | + | // `responseType = "blob"` up front breaks every ordinary swap, since | |
| 96 | + | // htmx reads the same field to get its markup. `overrideMimeType` with | |
| 97 | + | // `x-user-defined` does the same damage to every UTF-8 page. Refetching | |
| 98 | + | // the URL as a blob is correct and runs the route a second time, which | |
| 99 | + | // is wrong for a POST. The route this serves is scoped to a task | |
| 100 | + | // database by its own ruling; a binary payload over htmx is filed | |
| 101 | + | // rather than guessed at. | |
| 102 | + | // | |
| 103 | + | // Nothing here is wrong for a plain link, which never reaches this file | |
| 104 | + | // at all: the browser navigates, reads the same header, and saves the | |
| 105 | + | // bytes as they arrived. | |
| 106 | + | save(new Blob([xhr.response], { type }), named(header) || "download"); | |
| 107 | + | }); | |
| 108 | + | })(); |
| @@ -1,0 +1,95 @@ | |||
| 1 | + | // Putting a chosen value into the box the reader was already typing in. | |
| 2 | + | // | |
| 3 | + | // `f35aafee`. An `Act` can name a field on the same screen that receives its | |
| 4 | + | // value, and this is the browser's half of "the renderer decides where in it | |
| 5 | + | // the value lands". Here that is the selection: a reader who has put the caret | |
| 6 | + | // in the middle of a paragraph and opened a picker means to insert there, and | |
| 7 | + | // that is what every editor they have ever used does. | |
| 8 | + | // | |
| 9 | + | // It reads two attributes the node emitter writes and nothing else: `data-fills` | |
| 10 | + | // names the field, `data-fill` carries the value. Both are ordinary escaped | |
| 11 | + | // attributes, so nothing here is a program built out of app text -- which is why | |
| 12 | + | // the behaviour is a script rather than one of the emitted _hyperscript | |
| 13 | + | // programs, whose one rule is that no program is written from text a user typed. | |
| 14 | + | // A media file called `"; alert(1); "` is a value in an attribute here and could | |
| 15 | + | // not be one in a program. | |
| 16 | + | // | |
| 17 | + | // What it does NOT do is call anything. The act's own action is emitted beside | |
| 18 | + | // this and htmx performs it as it would any other; an act that only fills says | |
| 19 | + | // so with `Destination::Local`, which emits no transport at all. | |
| 20 | + | (() => { | |
| 21 | + | "use strict"; | |
| 22 | + | ||
| 23 | + | /** The field the act writes into. */ | |
| 24 | + | const FIELD = "data-fills"; | |
| 25 | + | /** What lands there. */ | |
| 26 | + | const VALUE = "data-fill"; | |
| 27 | + | ||
| 28 | + | /** | |
| 29 | + | * The box on this document under that name. | |
| 30 | + | * | |
| 31 | + | * By `name` rather than by `id`, because `name` is what the description | |
| 32 | + | * carries: it is what a submit sends the value under and what `Field::changes` | |
| 33 | + | * names. An id is scoped per form instance by the emitter and is therefore | |
| 34 | + | * not the string the description wrote. | |
| 35 | + | * | |
| 36 | + | * Searched from the pressed control outward: the nearest enclosing form | |
| 37 | + | * first, then the document. A screen showing the same form twice -- an | |
| 38 | + | * edit modal over a list -- otherwise gets the first copy in the document | |
| 39 | + | * whichever one the reader is in. | |
| 40 | + | */ | |
| 41 | + | const box = (control, name) => { | |
| 42 | + | const selector = `[name="${CSS.escape(name)}"]`; | |
| 43 | + | return control.closest("form")?.querySelector(selector) | |
| 44 | + | ?? document.querySelector(selector); | |
| 45 | + | }; | |
| 46 | + | ||
| 47 | + | /** | |
| 48 | + | * Put `text` where the caret is, and leave the caret after it. | |
| 49 | + | * | |
| 50 | + | * `selectionStart` is null on an input whose type has no text selection -- | |
| 51 | + | * a colour or a date -- and on anything that is not a text control at all. | |
| 52 | + | * Appending is the honest fallback there: the value still arrives, which is | |
| 53 | + | * what the description asked for, and the position was never described. | |
| 54 | + | */ | |
| 55 | + | const insert = (target, text) => { | |
| 56 | + | const value = target.value ?? ""; | |
| 57 | + | const at = typeof target.selectionStart === "number" ? target.selectionStart : value.length; | |
| 58 | + | const stop = typeof target.selectionEnd === "number" ? target.selectionEnd : at; | |
| 59 | + | ||
| 60 | + | target.value = value.slice(0, at) + text + value.slice(stop); | |
| 61 | + | const after = at + text.length; | |
| 62 | + | try { | |
| 63 | + | target.setSelectionRange(after, after); | |
| 64 | + | } catch { | |
| 65 | + | // Same class of control as the null above: it holds a value and has | |
| 66 | + | // no selection to set. The text is in it either way. | |
| 67 | + | } | |
| 68 | + | // What a browser sends when a control's value settles, so an autosave | |
| 69 | + | // debounce, a word count and a `Field::changes` route all see this the | |
| 70 | + | // way they see typing. Without it the three surfaces this was measured | |
| 71 | + | // on would show the reference and save a document without it. | |
| 72 | + | target.dispatchEvent(new Event("input", { bubbles: true })); | |
| 73 | + | }; | |
| 74 | + | ||
| 75 | + | // One delegated listener rather than one per control. A picker draws a card | |
| 76 | + | // per media file and its markup arrives in a swap, so binding per element | |
| 77 | + | // would mean rebinding on every swap; the document has been listening the | |
| 78 | + | // whole time instead. | |
| 79 | + | document.addEventListener("click", (event) => { | |
| 80 | + | const control = event.target?.closest?.(`[${FIELD}]`); | |
| 81 | + | if (!control) { | |
| 82 | + | return; | |
| 83 | + | } | |
| 84 | + | const target = box(control, control.getAttribute(FIELD)); | |
| 85 | + | if (!target) { | |
| 86 | + | // A description naming a field that is not on the screen. Nothing | |
| 87 | + | // is written and nothing is broken: the act's own action still | |
| 88 | + | // runs, which is the same shape as a renderer ignoring a key it | |
| 89 | + | // does not know. | |
| 90 | + | return; | |
| 91 | + | } | |
| 92 | + | target.focus(); | |
| 93 | + | insert(target, control.getAttribute(VALUE) ?? ""); | |
| 94 | + | }); | |
| 95 | + | })(); |