mirror of
https://github.com/tabler/tabler.git
synced 2026-10-12 05:06:55 +04:00
Fix Chart and Signature evaluating JS strings (#3226)
This commit is contained in:
1 parent
01857d234a
commit
6c1d546583
8 files changed
+157
-133
No files matched your search
@@ -77,9 +77,12 @@ Two known snags:
|
||||
- **Astro wraps a `define:vars` script in an IIFE.** Anything the markup calls (`onclick="setPageListItems(this)"`) must be assigned to `window` explicitly — see `shared/components/demo/AdvancedTable.astro`. If several instances define the same global, make it stateless and resolve the instance from the clicked element.
|
||||
- The type checker does not see `define:vars` bindings used inside template literals. `shared/components/marketing/hero/Side.astro` carries a `// @ts-nocheck` for exactly that; copy the comment with its explanation rather than restructuring the script.
|
||||
|
||||
## 5. When the script has to be a string
|
||||
## 5. No caller-supplied JS
|
||||
|
||||
If caller-supplied JS must land *inside* a function body, `define:vars` cannot do it — it injects variables, not code. Then build the script as a template string in the frontmatter and render it with `<Fragment set:html={script} />` inside `<CaptureScript>` (see `shared/ui/Signature.astro`). Serialise every interpolated value with `JSON.stringify` and keep raw-JS props documented as an escape hatch.
|
||||
Never accept JavaScript as a string — no raw-JS props, no expressions in data files turned into code with `new Function` or `set:html`. They are `eval` sinks and block a strict CSP. Instead:
|
||||
|
||||
- Page-specific behaviour goes in the page's own `<CaptureScript>` and reaches the plugin instance through its `window.tabler_*` registry (see `preview/pages/signatures.astro`).
|
||||
- Behaviour picked from data uses a whitelist of names mapped to real functions inside the script (see the chart `x-formatter` / `y-formatter` in `shared/components/demo/Chart.astro`).
|
||||
|
||||
## 6. Instance registries
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@tabler/preview": patch
|
||||
"@tabler/docs": patch
|
||||
---
|
||||
|
||||
Removed JS string evaluation from `Chart` axis formatters in `charts.json` and the `extraJs` prop from `Signature`.
|
||||
@@ -1,5 +1,4 @@
|
||||
---
|
||||
// Raw JS passed as Signature `extraJs` (appended inside the SignaturePad init).
|
||||
import CardSubtitle from '@ui/CardSubtitle.astro'
|
||||
import DefaultLayout from '@shared/layouts/DefaultLayout.astro'
|
||||
import Signature from '@ui/Signature.astro'
|
||||
@@ -11,51 +10,10 @@ import CardTitle from '@ui/CardTitle.astro'
|
||||
import FormGroup from '@ui/FormGroup.astro'
|
||||
import CaptureModal from '@shared/components/CaptureModal.astro'
|
||||
import DocsLink from '@ui/DocsLink.astro'
|
||||
import CaptureScript from '@shared/components/CaptureScript.astro'
|
||||
|
||||
const simpleJs = `signaturePad.fromDataURL("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCA1OTIgMjAwIj4KICA8ZyBjbGlwLXBhdGg9InVybCgjYSkiPgogICAgPHBhdGggZmlsbD0iIzAwMCIgZD0iTTEzMi4wOTIgNzkuMDY1YzAgLjMwNC0uMzU1IDEuNDAzLTEuMDYzIDMuMjk4LS42OTQgMS44OTQtMS4xNDIgMy4wMjItMS4zNDUgMy4zODQtMi4zNDIgMy44NDctNi43NiA3LjE4LTEzLjI1MiAxMC4wMDEtNS4zNjUgMi4yMjctMTMuNDU1IDUuNTktMjQuMjcgMTAuMDg4YTE0OC4xOSAxNDguMTkgMCAwIDAtMTAuMzQ3IDMuMTI0Yy0yLjY5Ljg4Mi02LjU4NiAyLjk1LTExLjY5IDYuMjA0YTE4Ny42NiAxODcuNjYgMCAwIDAtMS4wNjMgMTAuMTk2IDQwNy40OSA0MDcuNDkgMCAwIDEtMS40MzIgMTUuMzM4Yy0uODgyIDEuMTg2LTEuOTg4IDEuNzc5LTMuMzE4IDEuNzc5LjExNSAwIC4xNzMtLjAxNS4xNzMtLjA0NC0uMjAyLS4xMDEtLjg5Ni0uMjgyLTIuMDgyLS41NDJ2LTIuODg1YzAtMi43MDUuMjMxLTUuNDYuNjk0LTguMjY2IDAtLjA1Ny4yNTMtLjc3My43Ni0yLjE0Ny41MDUtMS4zMzEuNzU4LTIuMTQxLjc1OC0yLjQzIDAtMS42MDUtLjI5Ni0zLjI0LS44ODktNC45MDMtLjM5IDEuMTI4LTEuMTEzIDEuODU5LTIuMTY5IDIuMTkxLTEuMjQ0LS43MjMtMS44NjUtMS45ODgtMS44NjUtMy43OTYgMC0uMjAzIDEuMDQ4LTEuNzI5IDMuMTQ1LTQuNTc4IDIuMTQtMi45NjUgMy4zODMtNS4wMDQgMy43My02LjExNy4yNzUtLjgxLjc2Ny0zLjQ1IDEuNDc1LTcuOTE5IDEuMDQxLTYuNjEgMS45Ni0xMS40NCAyLjc1NS0xNC40OTEgMS42OTItNi42MzkgMy42OTQtMTAuODYyIDYuMDA4LTEyLjY3bDIuNDMgMS44MjN2MS44NjVjLTEuNTA1IDEuMTU3LTIuNTY3IDMuMjYyLTMuMTkgNi4zMTMtLjE4Ny45NC0uNjM2IDMuMzQxLTEuMzQ0IDcuMjAzLS4xNDUuNzA4LS43MjMgMi41NDUtMS43MzUgNS41MS0uOTExIDIuNjAzLTEuMzY3IDQuMTg3LTEuMzY3IDQuNzUxdjUuNDI0Yy4yNi4yNDUuNS4zNjguNzE2LjM2OCAxLjIxNSAwIDMuMTA5LS41MDYgNS42ODMtMS41MTggMy4wOC0xLjE1NyA1LjAxNy0xLjg1MSA1LjgxMy0yLjA4MyAxMy4wNzEtMy4zODQgMjMuMTUtNy4yMzggMzAuMjM1LTExLjU2MyA2LjIxOC0zLjc0NiA5Ljk5Mi02LjI5OCAxMS4zMjItNy42NTggMS41NzYtMS41NjIgMi4zNjQtMy42NjYgMi4zNjQtNi4zMTMgMC00LjY3MS0yLjEwNC04LjU2OS02LjMxMi0xMS42OTMtMy44Ni0yLjkwNy04LjE5MS00LjM2LTEyLjk5Mi00LjM2LTMuNzg4IDAtOC4yNzEuNDYyLTEzLjQ0NyAxLjM4OC02LjY2NiAxLjIxNS0xMi45MiAyLjkwNy0xOC43NjIgNS4wNzYtOC44NjQgMy4yODQtMTMuNzM2IDYuODM0LTE0LjYxOCAxMC42NTJsLTEuMDg1LjgwM0M1OC44MzkgNzguNDIyIDU4IDc3LjA2MiA1OCA3NS43OWMwLTEuODA4IDEuODgtNC4wMTQgNS42NC02LjYxNyAzLjA4LTIuMTQgNS44Mi0zLjU2NSA4LjIyLTQuMjc0IDE1Ljk0OS00LjkxNyAyNy44NjQtNy4zNzYgMzUuNzQ0LTcuMzc2IDcuNDYxIDAgMTMuNDEyIDEuOTUzIDE3Ljg1MSA1Ljg1NyA0LjQyNCAzLjg2MiA2LjYzNyA5LjA5IDYuNjM3IDE1LjY4NVptMzIuMjk1IDU1LjQwN3YxLjkwOWMtLjU5Mi44MjQtMS4yNDMgMS4yMzYtMS45NTIgMS4yMzYtLjQ0OCAwLTEuOTczLTEuMzU5LTQuNTc2LTQuMDc4LTIuNjAzLTIuNzMzLTQuMzM4LTQuMS01LjIwNi00LjEgMCAuMDU4LTMuMDggMS4yNDQtOS4yMzkgMy41NTgtNi4zMDUgMi4zNDMtMTAuMzE3IDMuNTE0LTEyLjAzOCAzLjUxNC0xLjQxNyAwLTIuOTg2LS42OTQtNC43MDctMi4wODMtMS43Mi0xLjM3NC0yLjU4MS0yLjY2OC0yLjU4MS0zLjg4MyAwLTMuMTM4IDMuNDM0LTYuNzk3IDEwLjMwMy0xMC45NzcgNi40MzQtMy45NjMgMTEuMzQzLTUuOTQ0IDE0LjcyNy01Ljk0NCAxLjQxNyAwIDIuNjkuODEgMy44MTcgMi40M3YxLjgyMmMtLjU2NC44MS0xLjIxNCAxLjIxNS0xLjk1MiAxLjIxNS0uMTMgMC0uNjE0LS4xODEtMS40NTMtLjU0My0uODM5LS4zNzYtMS4zMzctLjU2NC0xLjQ5Ni0uNTY0LTEuMTI4IDAtMy43MTcgMS4xNjUtNy43NjUgMy40OTMtMy44MzIgMi4xNjktNi4zOTkgMy43ODktNy43IDQuODYtMS4zMDIgMS4xMjgtMi4xMzMgMi40MTUtMi40OTQgMy44NjEuOTgzLjkyNiAyLjE1NCAxLjM4OCAzLjUxMyAxLjM4OC43MDkgMCA0LjEwNy0xLjE3MSAxMC4xOTQtMy41MTQgNi4wNzMtMi4zNzIgOS43NDYtMy41NTggMTEuMDE5LTMuNTU4IDEuODY1IDAgMy4yMS40NyA0LjAzNCAxLjQxLjE4OC4yMTcuOTA0IDEuNDkgMi4xNDcgMy44MThhMjEuOTAyIDIxLjkwMiAwIDAgMCAzLjQwNSA0LjczWm02OS44ODQtMTkuNzQydjEuOTUzYy0yLjk5MyAyLjQ1OC01Ljg5OSA1LjIxMy04LjcxOSA4LjI2NS04Ljc2MiA5LjQ1OS0xNC42NjIgMTQuMTg4LTE3LjY5OSAxNC4xODgtMi40NTggMC01LjE5OC0yLjIyLTguMjItNi42NmwtNi41MjgtOS42NTRjLTEuMjQ0LjIwMy0zLjQ5MiAyLjQ2Ni02Ljc0NiA2Ljc5LTMuMDUxIDQuMTIyLTUuODQyIDYuMTgzLTguMzcyIDYuMTgzbC0xLjE5My0uNjI5Yy0uODgyLTEuNjItMi41MzgtNS4xNy00Ljk2Ny0xMC42NTItMi42NDYtNS45MjktMy45NjktOS4zMDYtMy45NjktMTAuMTMxIDAtMS40MzIuODQ2LTIuNjE4IDIuNTM4LTMuNTU4IDEuNjkxIDIuMDQgMy4zNTQgNC44NjcgNC45ODggOC40ODMgMi40NTggNS4zMzYgMy44NzUgOC4zMyA0LjI1MSA4Ljk4MWgyLjA2MWMuNTY0LTEuMDQxIDEuODk0LTIuNjU0IDMuOTkxLTQuODM4LjczNy0xLjEyOCAxLjgyMi0yLjY5IDMuMjUzLTQuNjg2IDEuNjc3LTIuMzcyIDMuMDk1LTMuNTU3IDQuMjUxLTMuNTU3IDEuMDcgMCAxLjk3NC41OTIgMi43MTEgMS43NzggMS4wNzEgMS42OTMgMi45ODYgNC43MyA1Ljc0OCA5LjExMiAzLjA1MSA0Ljc0NCA1LjMgNy4xMTYgNi43NDYgNy4xMTYgMS4wNyAwIDUuMDgyLTMuMDgxIDEyLjAzNy05LjI0MiA2Ljk3LTYuMTYxIDEwLjc5NS05LjI0MiAxMS40NzQtOS4yNDJoMi4zNjRabTQ0LjU3MyAxOS41MjVjLTEuMDQyIDEuMjQ0LTIuNDAxIDEuODY2LTQuMDc4IDEuODY2aC0xNC45ODhjLTMuNDcgMC03LjMzOC0uNTIxLTExLjYwNC0xLjU2Mi01LjU5NS0xLjM2LTguNzYyLTMuMDY3LTkuNS01LjEyLS44MDktMi4zMTQtMS4yMTQtNC41MzQtMS4yMTQtNi42NiAwLTQuMjY3IDEuODM2LTcuNTQzIDUuNTA5LTkuODI4IDMuMDk0LTEuOTgxIDYuODc2LTIuOTcyIDExLjM0NC0yLjk3MiAzLjg0NiAwIDYuODk3LjgzMiA5LjE1MyAyLjQ5NSAyLjI1NSAxLjY0OSAzLjM4MyAzLjg5OCAzLjM4MyA2Ljc0NyAwIDEuNTkxLTIuNzExIDMuMzI2LTguMTMzIDUuMjA2LTQuNDgzIDEuNjItNy43NDQgMi40My05Ljc4MiAyLjQzYTQuNDkgNC40OSAwIDAgMS0uNjczLS4wNDNjMCAuMzktLjMxMS44NjgtLjkzMiAxLjQzMi4zMzIuNTY0IDEuOTgxIDEuMjE0IDQuOTQ1IDEuOTUyIDIuNjYuNjggNC41NTUgMS4wMiA1LjY4MiAxLjAyaDEzLjQ3YzAgLjAyOSAxLjY5OS4yODIgNS4wOTcuNzU5IDEuMDcuNjggMS44NDMgMS40MzkgMi4zMjEgMi4yNzhabS0xNy40NjEtMTYuOWMtMS41NzYtMS42MDUtNC4xMTMtMi40MDgtNy42MTMtMi40MDgtMi40NTggMC00LjczNS40NzctNi44MzIgMS40MzItMi43MDQgMS4yNDQtNC4wNTYgMi45MjktNC4wNTYgNS4wNTUgMCAuNzUyLjI1MyAxLjI4Ny43NTkgMS42MDVoMLine truncated
|
||||
|
||||
const advancedJs = `function download(dataURL, filename) {
|
||||
const blob = dataURLToBlob(dataURL);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.style = "display: none";
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function dataURLToBlob(dataURL) {
|
||||
const parts = dataURL.split(';base64,');
|
||||
const contentType = parts[0].split(":")[1];
|
||||
const raw = window.atob(parts[1]);
|
||||
const rawLength = raw.length;
|
||||
const uInt8Array = new Uint8Array(rawLength);
|
||||
|
||||
for (let i = 0; i < rawLength; ++i) {
|
||||
uInt8Array[i] = raw.charCodeAt(i);
|
||||
}
|
||||
|
||||
return new Blob([uInt8Array], { type: contentType });
|
||||
}
|
||||
|
||||
document.querySelector("#signature-advanced-color").addEventListener("input", function (e) {
|
||||
signaturePad.penColor = e.target.value;
|
||||
});
|
||||
|
||||
document.querySelector("#signature-advanced-svg").addEventListener("click", function () {
|
||||
const dataURL = signaturePad.toDataURL('image/svg+xml');
|
||||
download(dataURL, "signature.svg");
|
||||
});
|
||||
|
||||
document.querySelector("#signature-advanced-png").addEventListener("click", function () {
|
||||
const dataURL = signaturePad.toDataURL();
|
||||
download(dataURL, "signature.png");
|
||||
});`
|
||||
const signatureImage =
|
||||
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCA1OTIgMjAwIj4KICA8ZyBjbGlwLXBhdGg9InVybCgjYSkiPgogICAgPHBhdGggZmlsbD0iIzAwMCIgZD0iTTEzMi4wOTIgNzkuMDY1YzAgLjMwNC0uMzU1IDEuNDAzLTEuMDYzIDMuMjk4LS42OTQgMS44OTQtMS4xNDIgMy4wMjItMS4zNDUgMy4zODQtMi4zNDIgMy44NDctNi43NiA3LjE4LTEzLjI1MiAxMC4wMDEtNS4zNjUgMi4yMjctMTMuNDU1IDUuNTktMjQuMjcgMTAuMDg4YTE0OC4xOSAxNDguMTkgMCAwIDAtMTAuMzQ3IDMuMTI0Yy0yLjY5Ljg4Mi02LjU4NiAyLjk1LTExLjY5IDYuMjA0YTE4Ny42NiAxODcuNjYgMCAwIDAtMS4wNjMgMTAuMTk2IDQwNy40OSA0MDcuNDkgMCAwIDEtMS40MzIgMTUuMzM4Yy0uODgyIDEuMTg2LTEuOTg4IDEuNzc5LTMuMzE4IDEuNzc5LjExNSAwIC4xNzMtLjAxNS4xNzMtLjA0NC0uMjAyLS4xMDEtLjg5Ni0uMjgyLTIuMDgyLS41NDJ2LTIuODg1YzAtMi43MDUuMjMxLTUuNDYuNjk0LTguMjY2IDAtLjA1Ny4yNTMtLjc3My43Ni0yLjE0Ny41MDUtMS4zMzEuNzU4LTIuMTQxLjc1OC0yLjQzIDAtMS42MDUtLjI5Ni0zLjI0LS44ODktNC45MDMtLjM5IDEuMTI4LTEuMTEzIDEuODU5LTIuMTY5IDIuMTkxLTEuMjQ0LS43MjMtMS44NjUtMS45ODgtMS44NjUtMy43OTYgMC0uMjAzIDEuMDQ4LTEuNzI5IDMuMTQ1LTQuNTc4IDIuMTQtMi45NjUgMy4zODMtNS4wMDQgMy43My02LjExNy4yNzUtLjgxLjc2Ny0zLjQ1IDEuNDc1LTcuOTE5IDEuMDQxLTYuNjEgMS45Ni0xMS40NCAyLjc1NS0xNC40OTEgMS42OTItNi42MzkgMy42OTQtMTAuODYyIDYuMDA4LTEyLjY3bDIuNDMgMS44MjN2MS44NjVjLTEuNTA1IDEuMTU3LTIuNTY3IDMuMjYyLTMuMTkgNi4zMTMtLjE4Ny45NC0uNjM2IDMuMzQxLTEuMzQ0IDcuMjAzLS4xNDUuNzA4LS43MjMgMi41NDUtMS43MzUgNS41MS0uOTExIDIuNjAzLTEuMzY3IDQuMTg3LTEuMzY3IDQuNzUxdjUuNDI0Yy4yNi4yNDUuNS4zNjguNzE2LjM2OCAxLjIxNSAwIDMuMTA5LS41MDYgNS42ODMtMS41MTggMy4wOC0xLjE1NyA1LjAxNy0xLjg1MSA1LjgxMy0yLjA4MyAxMy4wNzEtMy4zODQgMjMuMTUtNy4yMzggMzAuMjM1LTExLjU2MyA2LjIxOC0zLjc0NiA5Ljk5Mi02LjI5OCAxMS4zMjItNy42NTggMS41NzYtMS41NjIgMi4zNjQtMy42NjYgMi4zNjQtNi4zMTMgMC00LjY3MS0yLjEwNC04LjU2OS02LjMxMi0xMS42OTMtMy44Ni0yLjkwNy04LjE5MS00LjM2LTEyLjk5Mi00LjM2LTMuNzg4IDAtOC4yNzEuNDYyLTEzLjQ0NyAxLjM4OC02LjY2NiAxLjIxNS0xMi45MiAyLjkwNy0xOC43NjIgNS4wNzYtOC44NjQgMy4yODQtMTMuNzM2IDYuODM0LTE0LjYxOCAxMC42NTJsLTEuMDg1LjgwM0M1OC44MzkgNzguNDIyIDU4IDc3LjA2MiA1OCA3NS43OWMwLTEuODA4IDEuODgtNC4wMTQgNS42NC02LjYxNyAzLjA4LTIuMTQgNS44Mi0zLjU2NSA4LjIyLTQuMjc0IDE1Ljk0OS00LjkxNyAyNy44NjQtNy4zNzYgMzUuNzQ0LTcuMzc2IDcuNDYxIDAgMTMuNDEyIDEuOTUzIDE3Ljg1MSA1Ljg1NyA0LjQyNCAzLjg2MiA2LjYzNyA5LjA5IDYuNjM3IDE1LjY4NVptMzIuMjk1IDU1LjQwN3YxLjkwOWMtLjU5Mi44MjQtMS4yNDMgMS4yMzYtMS45NTIgMS4yMzYtLjQ0OCAwLTEuOTczLTEuMzU5LTQuNTc2LTQuMDc4LTIuNjAzLTIuNzMzLTQuMzM4LTQuMS01LjIwNi00LjEgMCAuMDU4LTMuMDggMS4yNDQtOS4yMzkgMy41NTgtNi4zMDUgMi4zNDMtMTAuMzE3IDMuNTE0LTEyLjAzOCAzLjUxNC0xLjQxNyAwLTIuOTg2LS42OTQtNC43MDctMi4wODMtMS43Mi0xLjM3NC0yLjU4MS0yLjY2OC0yLjU4MS0zLjg4MyAwLTMuMTM4IDMuNDM0LTYuNzk3IDEwLjMwMy0xMC45NzcgNi40MzQtMy45NjMgMTEuMzQzLTUuOTQ0IDE0LjcyNy01Ljk0NCAxLjQxNyAwIDIuNjkuODEgMy44MTcgMi40M3YxLjgyMmMtLjU2NC44MS0xLjIxNCAxLjIxNS0xLjk1MiAxLjIxNS0uMTMgMC0uNjE0LS4xODEtMS40NTMtLjU0My0uODM5LS4zNzYtMS4zMzctLjU2NC0xLjQ5Ni0uNTY0LTEuMTI4IDAtMy43MTcgMS4xNjUtNy43NjUgMy40OTMtMy44MzIgMi4xNjktNi4zOTkgMy43ODktNy43IDQuODYtMS4zMDIgMS4xMjgtMi4xMzMgMi40MTUtMi40OTQgMy44NjEuOTgzLjkyNiAyLjE1NCAxLjM4OCAzLjUxMyAxLjM4OC43MDkgMCA0LjEwNy0xLjE3MSAxMC4xOTQtMy41MTQgNi4wNzMtMi4zNzIgOS43NDYtMy41NTggMTEuMDE5LTMuNTU4IDEuODY1IDAgMy4yMS40NyA0LjAzNCAxLjQxLjE4OC4yMTcuOTA0IDEuNDkgMi4xNDcgMy44MThhMjEuOTAyIDIxLjkwMiAwIDAgMCAzLjQwNSA0LjczWm02OS44ODQtMTkuNzQydjEuOTUzYy0yLjk5MyAyLjQ1OC01Ljg5OSA1LjIxMy04LjcxOSA4LjI2NS04Ljc2MiA5LjQ1OS0xNC42NjIgMTQuMTg4LTE3LjY5OSAxNC4xODgtMi40NTggMC01LjE5OC0yLjIyLTguMjItNi42NmwtNi41MjgtOS42NTRjLTEuMjQ0LjIwMy0zLjQ5MiAyLjQ2Ni02Ljc0NiA2Ljc5LTMuMDUxIDQuMTIyLTUuODQyIDYuMTgzLTguMzcyIDYuMTgzbC0xLjE5My0uNjI5Yy0uODgyLTEuNjItMi41MzgtNS4xNy00Ljk2Ny0xMC42NTItMi42NDYtNS45MjktMy45NjktOS4zMDYtMy45NjktMTAuMTMxIDAtMS40MzIuODQ2LTIuNjE4IDIuNTM4LTMuNTU4IDEuNjkxIDIuMDQgMy4zNTQgNC44NjcgNC45ODggOC40ODMgMi40NTggNS4zMzYgMy44NzUgOC4zMyA0LjI1MSA4Ljk4MWgyLjA2MWMuNTY0LTEuMDQxIDEuODk0LTIuNjU0IDMuOTkxLTQuODM4LjczNy0xLjEyOCAxLjgyMi0yLjY5IDMuMjUzLTQuNjg2IDEuNjc3LTIuMzcyIDMuMDk1LTMuNTU3IDQuMjUxLTMuNTU3IDEuMDcgMCAxLjk3NC41OTIgMi43MTEgMS43NzggMS4wNzEgMS42OTMgMi45ODYgNC43MyA1Ljc0OCA5LjExMiAzLjA1MSA0Ljc0NCA1LjMgNy4xMTYgNi43NDYgNy4xMTYgMS4wNyAwIDUuMDgyLTMuMDgxIDEyLjAzNy05LjI0MiA2Ljk3LTYuMTYxIDEwLjc5NS05LjI0MiAxMS40NzQtOS4yNDJoMi4zNjRabTQ0LjU3MyAxOS41MjVjLTEuMDQyIDEuMjQ0LTIuNDAxIDEuODY2LTQuMDc4IDEuODY2aC0xNC45ODhjLTMuNDcgMC03LjMzOC0uNTIxLTExLjYwNC0xLjU2Mi01LjU5NS0xLjM2LTguNzYyLTMuMDY3LTkuNS01LjEyLS44MDktMi4zMTQtMS4yMTQtNC41MzQtMS4yMTQtNi42NiAwLTQuMjY3IDEuODM2LTcuNTQzIDUuNTA5LTkuODI4IDMuMDk0LTEuOTgxIDYuODc2LTIuOTcyIDExLjM0NC0yLjk3MiAzLjg0NiAwIDYuODk3LjgzMiA5LjE1MyAyLjQ5NSAyLjI1NSAxLjY0OSAzLjM4MyAzLjg5OCAzLjM4MyA2Ljc0NyAwIDEuNTkxLTIuNzExIDMuMzI2LTguMTMzIDUuMjA2LTQuNDgzIDEuNjItNy43NDQgMi40My05Ljc4MiAyLjQzYTQuNDkgNC40OSAwIDAgMS0uNjczLS4wNDNjMCAuMzktLjMxMS44NjgtLjkzMiAxLjQzMi4zMzIuNTY0IDEuOTgxIDEuMjE0IDQuOTQ1IDEuOTUyIDIuNjYuNjggNC41NTUgMS4wMiA1LjY4MiAxLjAyaDEzLjQ3YzAgLjAyOSAxLjY5OS4yODIgNS4wOTcuNzU5IDEuMDcuNjggMS44NDMgMS40MzkgMi4zMjEgMi4yNzhabS0xNy40NjEtMTYuOWMtMS41NzYtMS42MDUtNC4xMTMtMi40MDgtNy42MTMtMi40MDgtMi40NTggMC00LjczNS40NzctNi44MzIgMS40MzItMi43MDQgMS4yNDQtNC4wNTYgMi45MjktNC4wNTYgNS4wNTUgMCAuNzUyLjI1MyAxLjI4Ny43NTkgMS42MDVoMi43MTFjLjk1NSAwIDMuNTUtLjgwMyA3Ljc4Ny0yLjLine truncated
|
||||
---
|
||||
|
||||
<DefaultLayout title="Signatures" pageMenu="extra.signatures" pageLibs={['signature_pad']}>
|
||||
@@ -74,7 +32,7 @@ document.querySelector("#signature-advanced-png").addEventListener("click", func
|
||||
<input type="text" class="form-control" id="signature-last-name" name="last_name" />
|
||||
</FormGroup>
|
||||
<FormGroup label="Signature" required associate={false}>
|
||||
<Signature id="default" clear extraJs={simpleJs} />
|
||||
<Signature id="default" clear />
|
||||
</FormGroup>
|
||||
</form>
|
||||
<div class="text-secondary fs-5">I agree that the signature and initials will be the electronic representation of my signature and initials for all purposes when I (or my agent) use them on documents, including legally binding contracts - just the same as a pen-and-paper signature or initial.</div>
|
||||
@@ -96,7 +54,7 @@ document.querySelector("#signature-advanced-png").addEventListener("click", func
|
||||
<CardBody>
|
||||
<CardTitle>Advanced demo</CardTitle>
|
||||
|
||||
<Signature id="advanced" clear extraJs={advancedJs} />
|
||||
<Signature id="advanced" clear />
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="row">
|
||||
@@ -126,6 +84,49 @@ document.querySelector("#signature-advanced-png").addEventListener("click", func
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CaptureScript>
|
||||
<!-- BEGIN SIGNATURE DEMO -->
|
||||
<script is:inline define:vars={{ signatureImage }}>
|
||||
function download(dataURL, filename) {
|
||||
const a = document.createElement('a')
|
||||
a.style.display = 'none'
|
||||
a.href = dataURL
|
||||
a.download = filename
|
||||
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
}
|
||||
|
||||
function initSignatureDemo() {
|
||||
const signatures = window.tabler_signature ?? {}
|
||||
|
||||
signatures.default?.fromDataURL(signatureImage)
|
||||
|
||||
const advanced = signatures.advanced
|
||||
|
||||
if (!advanced) {
|
||||
return
|
||||
}
|
||||
|
||||
document.getElementById('signature-advanced-color')?.addEventListener('input', function (e) {
|
||||
advanced.penColor = e.target.value
|
||||
})
|
||||
|
||||
document.getElementById('signature-advanced-svg')?.addEventListener('click', function () {
|
||||
download(advanced.toDataURL('image/svg+xml'), 'signature.svg')
|
||||
})
|
||||
|
||||
document.getElementById('signature-advanced-png')?.addEventListener('click', function () {
|
||||
download(advanced.toDataURL(), 'signature.png')
|
||||
})
|
||||
}
|
||||
|
||||
document.readyState !== 'loading' ? initSignatureDemo() : document.addEventListener('DOMContentLoaded', initSignatureDemo, { once: true })
|
||||
</script>
|
||||
<!-- END SIGNATURE DEMO -->
|
||||
</CaptureScript>
|
||||
|
||||
<CaptureModal>
|
||||
<div class="modal" tabindex="-1" id="modal-signature" role="dialog" aria-modal="true" aria-labelledby="modal-signature-save-title">
|
||||
<div class="modal-dialog">
|
||||
|
||||
@@ -42,7 +42,7 @@ if (size === 'sm') {
|
||||
// The :root custom-property block stays a plain string rendered via set:html — it's
|
||||
// inert CSS text (no injection surface), not executable code.
|
||||
const style = data ? chartStyle({ id, data }) : '';
|
||||
const { config, xFormatterExpr, yFormatterExpr } = data ? chartConfig({ id, data, height }) : { config: undefined, xFormatterExpr: undefined, yFormatterExpr: undefined };
|
||||
const { config, xFormatter, yFormatter } = data ? chartConfig({ id, data, height }) : { config: undefined, xFormatter: undefined, yFormatter: undefined };
|
||||
const chartKey = `chart-${id}`;
|
||||
const elementId = `chart-${id}`;
|
||||
---
|
||||
@@ -54,9 +54,14 @@ const elementId = `chart-${id}`;
|
||||
<Fragment set:html={style} />
|
||||
<CaptureScript>
|
||||
<!-- BEGIN CHART -->
|
||||
<script is:inline define:vars={{ chartKey, elementId, config, xFormatterExpr, yFormatterExpr }}>
|
||||
<script is:inline define:vars={{ chartKey, elementId, config, xFormatter, yFormatter }}>
|
||||
window.tabler_chart ??= {};
|
||||
|
||||
const formatters = {
|
||||
thousands: (val) => val.toLocaleString('en-US'),
|
||||
'suffix-k': (val) => val + 'K',
|
||||
};
|
||||
|
||||
// Heatmap and treemap shade each cell by computing off the literal base color in JS
|
||||
// (light -> full color-mix by value), unlike other chart types where the
|
||||
// SVG `fill` attribute just holds `var(--chart-...)` and the browser paints
|
||||
@@ -85,12 +90,12 @@ const elementId = `chart-${id}`;
|
||||
}
|
||||
|
||||
function initChart() {
|
||||
if (xFormatterExpr && config.xaxis?.labels) {
|
||||
config.xaxis.labels.formatter = new Function('val', `return (${xFormatterExpr})`);
|
||||
if (Object.hasOwn(formatters, xFormatter) && config.xaxis?.labels) {
|
||||
config.xaxis.labels.formatter = formatters[xFormatter];
|
||||
}
|
||||
|
||||
if (yFormatterExpr && config.yaxis?.labels) {
|
||||
config.yaxis.labels.formatter = new Function('val', `return (${yFormatterExpr})`);
|
||||
if (Object.hasOwn(formatters, yFormatter) && config.yaxis?.labels) {
|
||||
config.yaxis.labels.formatter = formatters[yFormatter];
|
||||
}
|
||||
|
||||
if ((config.chart?.type === 'heatmap' || config.chart?.type === 'treemap') && Array.isArray(config.colors)) {
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"height": 18,
|
||||
"datetime": true,
|
||||
"stroke-curve": "straight",
|
||||
"y-formatter": "val.toLocaleString(\"en-US\")",
|
||||
"y-formatter": "thousands",
|
||||
"series": [
|
||||
{
|
||||
"name": "Mobile",
|
||||
@@ -72,7 +72,7 @@
|
||||
"horizontal": true,
|
||||
"stacked": true,
|
||||
"categories": [2019, 2020, 2021, 2022, 2023, 2024, 2025],
|
||||
"x-formatter": "val + \"K\"",
|
||||
"x-formatter": "suffix-k",
|
||||
"series": [
|
||||
{
|
||||
"name": "North America",
|
||||
@@ -122,7 +122,7 @@
|
||||
"type": "line",
|
||||
"datetime": true,
|
||||
"stroke-curve": "straight",
|
||||
"y-formatter": "val.toLocaleString(\"en-US\")",
|
||||
"y-formatter": "thousands",
|
||||
"series": [
|
||||
{
|
||||
"name": "Total visits",
|
||||
@@ -302,7 +302,7 @@
|
||||
"stacked": true,
|
||||
"legend": true,
|
||||
"name": "Semantic colors",
|
||||
"y-formatter": "val.toLocaleString(\"en-US\")",
|
||||
"y-formatter": "thousands",
|
||||
"categories": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
|
||||
"series": [
|
||||
{ "name": "Hit", "color": "success", "data": [18000, 19200, 21000, 20300, 22000, 16600, 15000] },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { chartStyle, chartConfig, type ChartData } from './chart-script'
|
||||
import charts from '../data/charts.json'
|
||||
import { chartStyle, chartConfig, chartFormatters, type ChartData } from './chart-script'
|
||||
|
||||
describe('chartStyle', () => {
|
||||
it('emits an id-scoped color custom property per serie', () => {
|
||||
@@ -131,16 +132,21 @@ describe('chartConfig', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the x-formatter as a separate expression instead of embedding it', () => {
|
||||
const { config, xFormatterExpr } = chartConfig({ id: 'demo', data: { 'series': [], 'x-formatter': 'val + "K"' }, height: 10 })
|
||||
expect(xFormatterExpr).toBe('val + "K"')
|
||||
expect(JSON.stringify(config)).not.toContain('val + "K"')
|
||||
it('returns the formatter names separately instead of embedding them', () => {
|
||||
const { config, xFormatter, yFormatter } = chartConfig({ id: 'demo', data: { 'series': [], 'x-formatter': 'suffix-k', 'y-formatter': 'thousands' }, height: 10 })
|
||||
expect(xFormatter).toBe('suffix-k')
|
||||
expect(yFormatter).toBe('thousands')
|
||||
expect(JSON.stringify(config)).not.toMatch(/suffix-k|thousands/)
|
||||
})
|
||||
|
||||
it('returns the y-formatter as a separate expression instead of embedding it', () => {
|
||||
const { config, yFormatterExpr } = chartConfig({ id: 'demo', data: { 'series': [], 'y-formatter': 'val.toLocaleString("en-US")' }, height: 10 })
|
||||
expect(yFormatterExpr).toBe('val.toLocaleString("en-US")')
|
||||
expect(JSON.stringify(config)).not.toContain('toLocaleString')
|
||||
it('only uses known formatter names in charts.json', () => {
|
||||
const used = Object.values(charts as Record<string, Record<string, unknown>>)
|
||||
.flatMap((chart) => [chart['x-formatter'], chart['y-formatter']])
|
||||
.filter((name) => name !== undefined)
|
||||
expect(used.length).toBeGreaterThan(0)
|
||||
for (const name of used) {
|
||||
expect(chartFormatters).toContain(name)
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the ApexCharts 7 legend marker options', () => {
|
||||
|
||||
+11
-11
@@ -2,6 +2,10 @@
|
||||
// inert, not executable). chartConfig() returns a JSON-serializable config object,
|
||||
// rendered by Chart.astro via <script define:vars>.
|
||||
|
||||
/** Named axis label formatters, implemented in Chart.astro. */
|
||||
export const chartFormatters = ['thousands', 'suffix-k'] as const
|
||||
export type ChartFormatter = (typeof chartFormatters)[number]
|
||||
|
||||
type Serie = {
|
||||
'name'?: string
|
||||
/** overrides the chart type for this one serie — how a combination chart is built */
|
||||
@@ -69,8 +73,8 @@ export type ChartData = {
|
||||
'stroke-curve'?: string
|
||||
'hide-grid'?: boolean
|
||||
'show-x'?: boolean
|
||||
'x-formatter'?: string
|
||||
'y-formatter'?: string
|
||||
'x-formatter'?: ChartFormatter
|
||||
'y-formatter'?: ChartFormatter
|
||||
'y-max'?: number
|
||||
'y-title'?: string
|
||||
'y-tooltip'?: boolean
|
||||
@@ -173,15 +177,11 @@ export function chartStyle(opts: { id: string; data: ChartData }): string {
|
||||
|
||||
/**
|
||||
* The ApexCharts config, as a real JSON-serializable object (rendered via
|
||||
* <script define:vars> — no string-built script). The one exception is
|
||||
* `x-formatter` / `y-formatter`: a handful of charts.json entries carry a raw JS
|
||||
* expression for an axis label formatter (e.g. `val + "K"`), which can't be represented
|
||||
* as data. It comes back as `xFormatterExpr` / `yFormatterExpr` instead of being embedded in `config`,
|
||||
* for the caller to turn into a real function with `new Function` — see
|
||||
* Chart.astro. This is data-driven (from our own charts.json, not user input),
|
||||
* same trust level as everything else here.
|
||||
* <script define:vars> — no string-built script). Axis label formatters can't be
|
||||
* represented as data, so `x-formatter` / `y-formatter` name one of `chartFormatters`
|
||||
* and come back as `xFormatter` / `yFormatter`, for Chart.astro to map to a real function.
|
||||
*/
|
||||
export function chartConfig(opts: { id: string; data: ChartData; height: number }): { config: Record<string, unknown>; xFormatterExpr?: string | undefined; yFormatterExpr?: string | undefined } {
|
||||
export function chartConfig(opts: { id: string; data: ChartData; height: number }): { config: Record<string, unknown>; xFormatter?: ChartFormatter | undefined; yFormatter?: ChartFormatter | undefined } {
|
||||
const { id, data, height } = opts
|
||||
const type = data.type ?? 'bar'
|
||||
const series = data.series ?? []
|
||||
@@ -468,5 +468,5 @@ export function chartConfig(opts: { id: string; data: ChartData; height: number
|
||||
config.markers = { size: 2 }
|
||||
}
|
||||
|
||||
return { config, xFormatterExpr: data['x-formatter'], yFormatterExpr: data['y-formatter'] }
|
||||
return { config, xFormatter: data['x-formatter'], yFormatter: data['y-formatter'] }
|
||||
}
|
||||
+57
-54
@@ -9,75 +9,78 @@ interface Props {
|
||||
width?: number
|
||||
height?: number
|
||||
event?: string
|
||||
/** raw JS appended inside the init callback */
|
||||
extraJs?: string
|
||||
}
|
||||
|
||||
const { id, class: className, clear, width = 400, height = 400, event = 'DOMContentLoaded', extraJs } = Astro.props
|
||||
const { id, class: className, clear = false, width = 400, height = 400, event = 'DOMContentLoaded' } = Astro.props
|
||||
|
||||
const signatureClass = ['signature position-relative', className]
|
||||
|
||||
const clearSnippet = clear
|
||||
? ` document.querySelector(${JSON.stringify(`#signature-${id}-clear`)}).addEventListener("click", function () {
|
||||
signaturePad.clear();
|
||||
});
|
||||
`
|
||||
: ''
|
||||
|
||||
// extraJs is a documented escape hatch for raw JS (see Props) — not user data, so it
|
||||
// stays interpolated verbatim rather than JSON-serialized.
|
||||
const extraSnippet = extraJs
|
||||
? `${extraJs}
|
||||
`
|
||||
: ''
|
||||
|
||||
// set:html (below) is genuinely required here: extraJs is a documented raw-JS escape
|
||||
// hatch that must land *inside* the init callback body (it references canvas /
|
||||
// signaturePad), so the whole script has to be assembled as text — <script define:vars>
|
||||
// can only inject variables, not splice caller-supplied code into the function body.
|
||||
const script = `<!-- BEGIN SIGNATURE PAD -->
|
||||
<script>
|
||||
document.addEventListener(${JSON.stringify(event)}, function () {
|
||||
const canvas = document.getElementById(${JSON.stringify(`signature-${id}`)});
|
||||
|
||||
if (canvas) {
|
||||
const signaturePad = new SignaturePad(canvas, {
|
||||
backgroundColor: "transparent",
|
||||
penColor: getComputedStyle(canvas).color
|
||||
});
|
||||
|
||||
${clearSnippet}
|
||||
function resizeCanvas() {
|
||||
const ratio = Math.max(window.devicePixelRatio || 1, 1);
|
||||
|
||||
canvas.width = canvas.offsetWidth * ratio;
|
||||
canvas.height = canvas.offsetHeight * ratio;
|
||||
canvas.getContext("2d").scale(ratio, ratio);
|
||||
signaturePad.fromData(signaturePad.toData());
|
||||
}
|
||||
|
||||
window.addEventListener("resize", resizeCanvas);
|
||||
resizeCanvas();
|
||||
|
||||
${extraSnippet}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<!-- END SIGNATURE PAD -->`
|
||||
const signatureKey = id
|
||||
const canvasId = `signature-${id}`
|
||||
const clearId = `signature-${id}-clear`
|
||||
---
|
||||
|
||||
<div class:list={signatureClass}>
|
||||
{
|
||||
clear && (
|
||||
<div class="position-absolute top-0 end-0 p-2">
|
||||
<button type="button" class="btn btn-icon" id={`signature-${id}-clear`} title="Clear signature" aria-label="Clear signature" data-bs-toggle="tooltip">
|
||||
<button type="button" class="btn btn-icon" id={clearId} title="Clear signature" aria-label="Clear signature" data-bs-toggle="tooltip">
|
||||
<Icon name="trash" />{' '}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<canvas id={`signature-${id}`} width={width} height={height} class="signature-canvas"></canvas>
|
||||
<canvas id={canvasId} width={width} height={height} class="signature-canvas"></canvas>
|
||||
</div>
|
||||
<CaptureScript>
|
||||
<Fragment set:html={script} />
|
||||
<!-- BEGIN SIGNATURE PAD -->
|
||||
<script is:inline define:vars={{ signatureKey, canvasId, clearId, clear, event }}>
|
||||
window.tabler_signature ??= {}
|
||||
|
||||
function resizeCanvas(canvas, signaturePad) {
|
||||
const ratio = Math.max(window.devicePixelRatio || 1, 1)
|
||||
|
||||
canvas.width = canvas.offsetWidth * ratio
|
||||
canvas.height = canvas.offsetHeight * ratio
|
||||
canvas.getContext('2d').scale(ratio, ratio)
|
||||
signaturePad.fromData(signaturePad.toData())
|
||||
}
|
||||
|
||||
function initSignature() {
|
||||
const canvas = document.getElementById(canvasId)
|
||||
|
||||
if (!canvas) {
|
||||
return
|
||||
}
|
||||
|
||||
let signaturePad = window.tabler_signature[signatureKey]
|
||||
|
||||
if (!signaturePad) {
|
||||
signaturePad = new SignaturePad(canvas, {
|
||||
backgroundColor: 'transparent',
|
||||
penColor: getComputedStyle(canvas).color,
|
||||
})
|
||||
|
||||
window.tabler_signature[signatureKey] = signaturePad
|
||||
|
||||
if (clear) {
|
||||
document.getElementById(clearId)?.addEventListener('click', function () {
|
||||
signaturePad.clear()
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('resize', function () {
|
||||
resizeCanvas(canvas, signaturePad)
|
||||
})
|
||||
}
|
||||
|
||||
resizeCanvas(canvas, signaturePad)
|
||||
}
|
||||
|
||||
if (event === 'DOMContentLoaded') {
|
||||
document.readyState !== 'loading' ? initSignature() : document.addEventListener('DOMContentLoaded', initSignature, { once: true })
|
||||
} else {
|
||||
document.addEventListener(event, initSignature)
|
||||
}
|
||||
</script>
|
||||
<!-- END SIGNATURE PAD -->
|
||||
</CaptureScript>
|
||||
Reference in new issue
Block a user