diff --git a/core/include/internal/gpu.h b/core/include/internal/gpu.h index db2e8ee3a..fe7538080 100644 --- a/core/include/internal/gpu.h +++ b/core/include/internal/gpu.h @@ -188,8 +188,9 @@ class GPU { deviceDesc.SetDeviceLostCallback( wgpu::CallbackMode::AllowProcessEvents, [](const wgpu::Device&, wgpu::DeviceLostReason reason, wgpu::StringView msg) { - std::string err_msg = (msg.data && msg.length > 0) ? std::string(msg.data, msg.length) - : "Unknown device lost reason"; + std::string err_msg = (msg.data && msg.length > 0) + ? std::string(msg.data, msg.length) + : "Unknown device lost reason"; std::cerr << "[DEVICE LOST] Reason: " << static_cast(reason) << " Msg: " << err_msg << std::endl; }); diff --git a/core/src/internal/graph.cpp b/core/src/internal/graph.cpp index e583a5fee..7b5616765 100644 --- a/core/src/internal/graph.cpp +++ b/core/src/internal/graph.cpp @@ -140,7 +140,7 @@ void Graph::process_overlapping_edges() { for (const Node_ptr &n : get_nodes()) { if (n->area() == 0) continue; - + for (auto &[_, p] : n->get_pixels()) { label_map[p.y * m_width + p.x] = n->id(); } @@ -170,7 +170,6 @@ void Graph::process_overlapping_edges() { // Is it a neighbor? AND have we not processed this pairing yet? if (n_val != 0 && n_val != val && val < n_val) { - bool is_too_thin = false; // Check around the neighbor pixel for a 3rd region (pinching) @@ -181,16 +180,18 @@ void Graph::process_overlapping_edges() { if (mx < 0 || mx >= m_width || my < 0 || my >= m_height) continue; int32_t m_val = label_map[my * m_width + mx]; - + if (m_val != 0 && m_val != val && m_val != n_val) { is_too_thin = true; - break; // CRITICAL: Stop checking immediately once proven thin! + break; // CRITICAL: Stop checking immediately once proven thin! } } if (is_too_thin) { // Give our pixel to the neighbor - Node_ptr neighbor_node = m_nodes->at(m_node_ids[n_val]); // get_node_by_id(n_val); // Assuming you have this lookup + Node_ptr neighbor_node = + m_nodes->at(m_node_ids[n_val]); // get_node_by_id(n_val); // Assuming + // you have this lookup if (neighbor_node) { neighbor_node->add_edge_pixel(XY{x, y}); } @@ -213,7 +214,7 @@ void Graph::compute_contours() { if (n->area() == 0) continue; n->compute_contour(); } - + // smoothing std::vector> all_contours; for (const Node_ptr &n : get_nodes()) { @@ -256,7 +257,7 @@ void Graph::merge_small_area_nodes(const int32_t min_area) { std::copy(n->edges().begin(), n->edges().end(), std::back_inserter(neighbors)); ImageLib::RGBPixel col = n->color(); - + Node_ptr best_neighbor = nullptr; float best_score = std::numeric_limits::max(); for (const Node_ptr &ne : n->edges()) { @@ -271,8 +272,10 @@ void Graph::merge_small_area_nodes(const int32_t min_area) { } // no valid neighbor found, skip this node - if (!best_neighbor) { continue; } - + if (!best_neighbor) { + continue; + } + if (best_neighbor->area() >= n->area()) { merge_nodes(best_neighbor, n); } else { diff --git a/core/src/internal/labels_to_svg.cpp b/core/src/internal/labels_to_svg.cpp index 9ac434334..fa0463556 100644 --- a/core/src/internal/labels_to_svg.cpp +++ b/core/src/internal/labels_to_svg.cpp @@ -195,7 +195,7 @@ char *labels_to_svg(uint8_t *data, int32_t *labels, const int width, const int h // 1. enumerate regions and convert to Nodes std::vector nodes; region_labeling(data, labels_vector, region_labels, width, height, nodes); - + // 2. initialize Graph from all Nodes std::unique_ptr> node_ptr = std::make_unique>(std::move(nodes)); @@ -203,10 +203,10 @@ char *labels_to_svg(uint8_t *data, int32_t *labels, const int width, const int h // 3. Discover node adjacencies - add edges to Graph G.discover_edges(region_labels, width, height); - + // 4. Merge small area nodes until all nodes are minArea or larger G.merge_small_area_nodes(min_area); - + // 5. recolor image on new regions ImageLib::Image> results{width, height}; for (auto &n : G.get_nodes()) { @@ -221,7 +221,7 @@ char *labels_to_svg(uint8_t *data, int32_t *labels, const int width, const int h // 6. Contours // graph will manage computing contours G.compute_contours(); - + // accumulate all contours for svg export ColoredContours all_contours; for (auto &n : G.get_nodes()) { diff --git a/example-apps/react-js/package.json b/example-apps/react-js/package.json index b47a38d01..e2629663b 100644 --- a/example-apps/react-js/package.json +++ b/example-apps/react-js/package.json @@ -107,17 +107,18 @@ "lint:style": "editorconfig-checker" }, "dependencies": { + "@tanstack/react-query": "^5.95.0", "@testing-library/dom": "^10.4.1", "html-react-parser": "^5.2.15", "img2num": "workspace:*", + "jspdf": "^4.2.1", "lucide-react": "^0.577.0", "prop-types": "^15.8.1", "react": "^18.2.4", "react-dom": "^18.2.4", "react-helmet": "^6.1.0", "react-router-dom": "^7.13.1", - "react-tooltip": "^5.30.0", - "@tanstack/react-query": "^5.95.0" + "react-tooltip": "^5.30.0" }, "devDependencies": { "@eslint/js": "^9.39.2", diff --git a/example-apps/react-js/src/components/GlassCard.jsx b/example-apps/react-js/src/components/GlassCard.jsx index 88d1cdc2a..9ef39f6f3 100644 --- a/example-apps/react-js/src/components/GlassCard.jsx +++ b/example-apps/react-js/src/components/GlassCard.jsx @@ -1,12 +1,13 @@ +import { forwardRef } from "react"; import PropTypes from "prop-types"; import styles from "./GlassCard.module.css"; // eslint-disable-next-line no-unused-vars -const GlassCard = ({ as: Tag = "div", children, ...rest }) => ( - +const GlassCard = forwardRef(({ as: Tag = "div", children, ...rest }, ref) => ( + {children} -); +)); GlassCard.propTypes = { as: PropTypes.oneOf([ diff --git a/example-apps/react-js/src/components/GlassModal.jsx b/example-apps/react-js/src/components/GlassModal.jsx new file mode 100644 index 000000000..0989b21bf --- /dev/null +++ b/example-apps/react-js/src/components/GlassModal.jsx @@ -0,0 +1,66 @@ +import { useEffect } from "react"; +import { createPortal } from "react-dom"; +import PropTypes from "prop-types"; +import GlassCard from "@components/GlassCard"; +import styles from "./GlassModal.module.css"; +import { X } from "lucide-react"; + +export default function GlassModal({ + isOpen, + onClose, + children, + size = "lg", // sm | md | lg | any CSS width + showCloseButton = true, + closeOnBackdropClick = true, + className = "", + style = {}, +}) { + useEffect(() => { + const handleKey = (e) => { + if (e.key === "Escape") onClose?.(); + }; + if (isOpen) window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + const handleBackdropClick = (e) => { + if (e.target === e.currentTarget && closeOnBackdropClick) { + onClose?.(); + } + }; + + const width = size === "sm" ? "300px" : size === "md" ? "500px" : size === "lg" ? "800px" : size; + + const modal = ( + + ); + + return createPortal(modal, document.body); +} diff --git a/example-apps/react-js/src/components/GlassModal.module.css b/example-apps/react-js/src/components/GlassModal.module.css new file mode 100644 index 000000000..edca9c8d8 --- /dev/null +++ b/example-apps/react-js/src/components/GlassModal.module.css @@ -0,0 +1,54 @@ +.backdrop { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + animation: fadeIn 0.2s ease forwards; +} + +.modal { + position: relative; + border-radius: 1rem; + background: var(--color-bg); + padding: var(--spacing-xl) var(--spacing-lg) var(--spacing-lg); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25); + animation: scaleIn 0.2s ease forwards; + max-height: 90vh; + max-width: 95vw; + overflow-y: auto; + z-index: 1001; +} + +.closeButton { + position: absolute; + top: 0.5rem; + right: 0.5rem; + border: none; + cursor: pointer; + padding: var(--spacing-xs); + display: flex; + align-items: center; + justify-content: center; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes scaleIn { + from { + transform: scale(0.95); + opacity: 0; + } + to { + transform: scale(1); + opacity: 1; + } +} diff --git a/example-apps/react-js/src/components/GlassSwitch.module.css b/example-apps/react-js/src/components/GlassSwitch.module.css index 91b035763..12975e762 100644 --- a/example-apps/react-js/src/components/GlassSwitch.module.css +++ b/example-apps/react-js/src/components/GlassSwitch.module.css @@ -8,7 +8,6 @@ border-radius: 9999px; position: relative; cursor: pointer; - margin-right: 15px; } /* WCAG 2.4.7 Compliant Focus Indicator */ diff --git a/example-apps/react-js/src/global-styles/base.css b/example-apps/react-js/src/global-styles/base.css index 698cc7043..4d2f04f04 100644 --- a/example-apps/react-js/src/global-styles/base.css +++ b/example-apps/react-js/src/global-styles/base.css @@ -4,7 +4,8 @@ box-sizing: border-box; } -body { +body, +:not(:root):fullscreen::backdrop { margin: 0; padding: var(--spacing-lg); color: var(--color-text); @@ -60,6 +61,11 @@ img { } /* General link style */ +button.anchor-style { + padding: auto; + background: none; + border: none; +} a, .anchor-style { color: var(--color-primary); @@ -69,6 +75,7 @@ a, text-decoration 0.3s; cursor: pointer; text-decoration: underline; + cursor: pointer; } /* Hover/focus states */ diff --git a/example-apps/react-js/src/global-styles/utilities/layout.css b/example-apps/react-js/src/global-styles/utilities/layout.css index 571b04717..f3a1a8b17 100644 --- a/example-apps/react-js/src/global-styles/utilities/layout.css +++ b/example-apps/react-js/src/global-styles/utilities/layout.css @@ -26,6 +26,10 @@ display: flex; justify-content: space-between; } +.flex-wrap-wrap { + display: flex; + flex-wrap: wrap; +} .grid { display: grid; diff --git a/example-apps/react-js/src/hooks/useFullscreen.js b/example-apps/react-js/src/hooks/useFullscreen.js new file mode 100644 index 000000000..fa865a627 --- /dev/null +++ b/example-apps/react-js/src/hooks/useFullscreen.js @@ -0,0 +1,109 @@ +import { useEffect, useRef, useCallback } from "react"; + +/** + * React hook for managing fullscreen mode on an element. + * Supports modern browsers, Safari, IE11. + * Handles Escape key and browser back button to exit fullscreen. + * + * @param {React.RefObject} initialRef - Optional ref to the element to fullscreen + * @returns {{ + * ref: React.RefObject, + * open: () => void, + * close: () => void, + * toggle: () => void + * }} + */ +export default function useFullscreen(initialRef) { + const _fallbackRef = useRef(document.documentElement); + const ref = initialRef || _fallbackRef; + + // Track whether THIS hook pushed history + const hasPushedRef = useRef(false); + + const getFullscreenElement = () => document.fullscreenElement || document.webkitFullscreenElement || document.msFullscreenElement; + + const exitFullscreen = () => { + if (document.exitFullscreen) return document.exitFullscreen(); + if (document.webkitExitFullscreen) return document.webkitExitFullscreen(); + if (document.msExitFullscreen) return document.msExitFullscreen(); + }; + + const requestFullscreen = (elem) => { + if (elem.requestFullscreen) return elem.requestFullscreen(); + if (elem.webkitRequestFullscreen) return elem.webkitRequestFullscreen(); + if (elem.msRequestFullscreen) return elem.msRequestFullscreen(); + }; + + const close = useCallback(() => { + if (getFullscreenElement()) { + exitFullscreen(); + } + }, []); + + const open = useCallback(() => { + const elem = ref.current; + if (!elem) return; + + // Only request fullscreen — NO history manipulation here + requestFullscreen(elem); + }, [ref]); + + const toggle = useCallback(() => { + if (getFullscreenElement()) { + close(); + } else { + open(); + } + }, [open, close]); + + useEffect(() => { + const handleFullscreenChange = () => { + const isFullscreen = !!getFullscreenElement(); + + if (isFullscreen && !hasPushedRef.current) { + // Entered fullscreen → push history + window.history.pushState({ fullscreen: true }, ""); + window.addEventListener("popstate", close); + hasPushedRef.current = true; + } + + if (!isFullscreen && hasPushedRef.current) { + // Exited fullscreen → clean up history + window.removeEventListener("popstate", close); + + // Go back ONLY if we added a state + if (window.history.state?.fullscreen) { + window.history.back(); + } + + hasPushedRef.current = false; + } + }; + + document.addEventListener("fullscreenchange", handleFullscreenChange); + document.addEventListener("webkitfullscreenchange", handleFullscreenChange); + document.addEventListener("MSFullscreenChange", handleFullscreenChange); + + return () => { + document.removeEventListener("fullscreenchange", handleFullscreenChange); + document.removeEventListener("webkitfullscreenchange", handleFullscreenChange); + document.removeEventListener("MSFullscreenChange", handleFullscreenChange); + }; + }, [close]); + + useEffect(() => { + const handleEscape = (e) => { + // Only act if actually in fullscreen + if (e.key === "Escape" && getFullscreenElement()) { + close(); + } + }; + + document.addEventListener("keydown", handleEscape); + return () => { + document.removeEventListener("keydown", handleEscape); + }; + }, [close]); + + return { ref, open, close, toggle }; +} diff --git a/example-apps/react-js/src/pages/Editor/Editor.module.css b/example-apps/react-js/src/pages/Editor/Editor.module.css index 752303e6c..2ba8e6499 100644 --- a/example-apps/react-js/src/pages/Editor/Editor.module.css +++ b/example-apps/react-js/src/pages/Editor/Editor.module.css @@ -15,11 +15,7 @@ background: transparent; } /* Default shape appearance in color mode: hide original fills until revealed */ -.colorMode .viewport svg path:not(.coloredRegion), -.colorMode .viewport svg rect:not(.coloredRegion), -.colorMode .viewport svg circle:not(.coloredRegion), -.colorMode .viewport svg polygon:not(.coloredRegion), -.colorMode .viewport svg ellipse:not(.coloredRegion) { +.colorMode .viewport svg *:not(.coloredRegion) { fill: white; cursor: pointer; transition: 150ms ease; @@ -32,7 +28,7 @@ /* Inner wrapper where we apply transform(scale/translate) */ .inner { - transform-origin: 0 0; + transform-origin: center center; will-change: transform; transition: transform 50ms linear; display: inline-block; @@ -82,10 +78,19 @@ } .hint { - margin-top: 12px; + margin-top: var(--spacing-sm); font-size: 0.85rem; text-align: center; } +@media screen and (max-width: 757px) { + .hint { + display: none; + } + + .viewport { + margin-top: var(--spacing-sm); + } +} /* responsiveness */ @media (max-width: 800px) { @@ -96,3 +101,7 @@ min-height: 240px; } } + +.historyContainer { + width: 100%; +} diff --git a/example-apps/react-js/src/pages/Editor/EditorControls.jsx b/example-apps/react-js/src/pages/Editor/EditorControls.jsx new file mode 100644 index 000000000..0b0c3b93f --- /dev/null +++ b/example-apps/react-js/src/pages/Editor/EditorControls.jsx @@ -0,0 +1,262 @@ +import { jsPDF } from "jspdf"; +import GlassModal from "@components/GlassModal"; +import GlassSwitch from "@components/GlassSwitch"; +import GlassCard from "@components/GlassCard"; +import Tooltip from "@components/Tooltip"; +import { useState, useId } from "react"; +import { Undo, Redo, Save, Eye, Brush, Printer, Download, Copy, RotateCcw, Share2, FileText, Expand } from "lucide-react"; +import styles from "./EditorControls.module.css"; + +const EditorControls = ({ svg, fileName, isColorMode = false, setIsColorMode = () => {}, onReset = () => {}, onUndo = () => {}, onRedo = () => {}, onFullscreen = () => {} }) => { + const [modalOpen, setModalOpen] = useState(false); + const [copied, setCopied] = useState(false); + const switchId = useId(); + + const copySvg = () => { + navigator.clipboard.writeText(svg).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + }; + + const download = (content, name, type) => { + const a = Object.assign(document.createElement("a"), { + href: URL.createObjectURL(new Blob([content], { type })), + download: name, + }); + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(a.href); + }; + + const printSvg = () => { + const iframe = document.createElement("iframe"); + Object.assign(iframe.style, { position: "absolute", width: "0", height: "0", border: "0" }); + document.body.appendChild(iframe); + iframe.contentWindow.document.open(); + iframe.contentWindow.document.write(svg); + iframe.contentWindow.document.close(); + setTimeout(() => { + iframe.contentWindow.focus(); + iframe.contentWindow.print(); + document.body.removeChild(iframe); + }, 100); + }; + + const shareSvg = async () => { + if (!navigator.canShare || !svg) { + alert("Sharing not supported on this device."); + return; + } + const file = new File([svg], `${fileName}.svg`, { type: "image/svg+xml" }); + if (navigator.canShare({ files: [file] })) { + await navigator.share({ files: [file], title: fileName }).catch(console.error); + } else { + alert("Sharing this file type is not supported on your device."); + } + }; + + // Raster export (PNG / JPEG) — no dependencies needed + const exportRaster = (mimeType) => { + const img = new Image(); + const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" }); + const url = URL.createObjectURL(blob); + img.onload = () => { + const canvas = document.createElement("canvas"); + canvas.width = img.naturalWidth || 800; + canvas.height = img.naturalHeight || 600; + canvas.getContext("2d").drawImage(img, 0, 0); + canvas.toBlob((pngBlob) => { + const ext = mimeType === "image/jpeg" ? "jpg" : "png"; + download(pngBlob, `${fileName}.${ext}`, mimeType); + URL.revokeObjectURL(url); + }, mimeType); + }; + img.src = url; + }; + + // PDF export — using jsPDF (lightweight, ~200 kB gzipped) + const exportPDF = () => { + const img = new Image(); + const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" }); + const url = URL.createObjectURL(blob); + img.onload = () => { + const w = img.naturalWidth || 800; + const h = img.naturalHeight || 600; + const pdf = new jsPDF({ orientation: w > h ? "landscape" : "portrait", unit: "px", format: [w, h] }); + const canvas = document.createElement("canvas"); + canvas.width = w; + canvas.height = h; + canvas.getContext("2d").drawImage(img, 0, 0); + pdf.addImage(canvas.toDataURL("image/png"), "PNG", 0, 0, w, h); + pdf.save(`${fileName}.pdf`); + URL.revokeObjectURL(url); + }; + img.src = url; + }; + + if (!svg) return null; + + return ( + <> +
+ {copied ? "SVG copied to clipboard." : ""} +
+ + setModalOpen(false)} title="Save image"> + +

Export

+
    +
  • + + + +
  • +
  • + + + +
  • +
  • + + + +
  • +
  • + + + +
  • +
+
+ + +

Clipboard

+
    +
  • + + + +
  • +
+
+ + +

Other

+
    +
  • + + + +
  • +
  • + + + +
  • +
+
+
+ +
+
+ setIsColorMode((prev) => !prev)} + thumbContent={isColorMode ? : } + aria-checked={isColorMode} + ariaLabel={`Switch to ${isColorMode ? "preview" : "edit"} mode`} + role="switch" + /> + +
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + +
+
+ + ); +}; + +export default EditorControls; diff --git a/example-apps/react-js/src/pages/Editor/EditorControls.module.css b/example-apps/react-js/src/pages/Editor/EditorControls.module.css new file mode 100644 index 000000000..52653998d --- /dev/null +++ b/example-apps/react-js/src/pages/Editor/EditorControls.module.css @@ -0,0 +1,80 @@ +/* ── Switch label ── */ +.switchLabel { + color: var(--color-text); + cursor: pointer; + user-select: none; +} +@media screen and (max-width: 757px) { + .switchWrapper { + justify-content: left; + } + .switchLabel { + display: none; + } +} +@media screen and (max-width: 666px) { + .switchWrapper { + justify-content: center; + } +} + +/* ── Modal action list ── */ +.actionList { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.actionItem { + width: 100%; + font: inherit; +} +.actionItem svg { + color: var(--color-bg); +} +.actionItem span { + display: flex; + flex-direction: column; + line-height: 1.3; +} +.actionItem big { + font-weight: bold; +} +.actionItem small { + color: var(--color-muted); +} + +/* ── Screen-reader only ── */ +.srOnly { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +.wrapper > * { + flex: 1; +} +@media (min-width: 800px) { + .wrapper { + justify-content: space-between; + } +} + +.modalGroupContainer { + padding: var(--spacing-md); + display: grid; + gap: var(--spacing-sm); + h4 { + text-transform: uppercase; + margin: 0; + } +} diff --git a/example-apps/react-js/src/pages/Editor/index.jsx b/example-apps/react-js/src/pages/Editor/index.jsx index 96af92a29..d05c93296 100644 --- a/example-apps/react-js/src/pages/Editor/index.jsx +++ b/example-apps/react-js/src/pages/Editor/index.jsx @@ -1,25 +1,51 @@ -import { useRef, useState } from "react"; +import { useEffect, useRef, useState, useCallback } from "react"; import parse from "html-react-parser"; import { useLocation } from "react-router-dom"; -import { Eye, Brush } from "lucide-react"; import GlassCard from "@components/GlassCard"; -import styles from "./Editor.module.css"; +import GlassModal from "@components/GlassModal"; import EditorHelmet from "./EditorHelmet"; -import GlassSwitch from "@components/GlassSwitch"; +import EditorControls from "./EditorControls"; +import useFullscreen from "@hooks/useFullscreen"; + +import styles from "./Editor.module.css"; -// shape selector we care about const SHAPE_SELECTOR = "path,rect,circle,polygon,ellipse"; export default function Editor() { const { state } = useLocation(); const { svg } = state || {}; + const [svgElements] = useState(() => (svg ? parse(svg) : null)); const [isColorMode, setIsColorMode] = useState(true); + + const [history, setHistory] = useState([]); + const [historyIndex, setHistoryIndex] = useState(-1); + const [initialSnapshot, setInitialSnapshot] = useState([]); + + const [modalOpen, setModalOpen] = useState(false); + const viewportRef = useRef(null); const innerRef = useRef(null); - const [transform, setTransform] = useState({ scale: 1, tx: 0, ty: 0 }); - const pointerState = useRef({ dragging: false, lastX: 0, lastY: 0, moved: false }); + + const rafRef = useRef(null); + + const transformRef = useRef({ + scale: 1, + tx: 0, + ty: 0, + }); + + const { ref: fsRef, toggle: toggleFullscreen } = useFullscreen(); + + const pointerState = useRef({ + dragging: false, + lastX: 0, + lastY: 0, + moved: false, + }); + const activePointersRef = useRef(new Map()); + const pinchRef = useRef({ active: false, startDist: 0, @@ -32,95 +58,131 @@ export default function Editor() { const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); - // Wheel zoom (keeps tx/ty; can be improved later to zoom to pointer) - const handleWheel = (e) => { - const delta = Math.sign(e.deltaY); - setTransform((t) => { - const step = 1.12; - let nextScale = delta > 0 ? t.scale / step : t.scale * step; - nextScale = Math.max(0.25, Math.min(6, nextScale)); - return { ...t, scale: nextScale }; + const scheduleRender = () => { + if (rafRef.current) return; + + rafRef.current = requestAnimationFrame(() => { + rafRef.current = null; + + const { scale, tx, ty } = transformRef.current; + + if (innerRef.current) { + innerRef.current.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`; + } }); }; - // Panning handlers (pointer events) + const updateTransform = (callback) => { + callback(transformRef.current); + scheduleRender(); + }; + + // Wheel zoom + const handleWheel = useCallback( + (e) => { + e.preventDefault(); + + const delta = Math.sign(e.deltaY); + + updateTransform((t) => { + const step = 1.12; + let nextScale = delta > 0 ? t.scale / step : t.scale * step; + t.scale = clamp(nextScale, 0.25, 6); + }); + }, + [updateTransform], + ); + useEffect(() => { + const el = viewportRef.current; + if (!el) return; + + el.addEventListener("wheel", handleWheel, { passive: false }); + + return () => el.removeEventListener("wheel", handleWheel); + }, [handleWheel]); + const onPointerDown = (e) => { if (!viewportRef.current) return; - - // Only start a drag for primary button on mouse. if (e.pointerType === "mouse" && e.button !== 0) return; - activePointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); + activePointersRef.current.set(e.pointerId, { + x: e.clientX, + y: e.clientY, + }); pointerState.current.dragging = true; pointerState.current.moved = false; pointerState.current.lastX = e.clientX; pointerState.current.lastY = e.clientY; - // Pinch start when second pointer goes down if (activePointersRef.current.size === 2) { - const points = Array.from(activePointersRef.current.values()); - const p1 = points[0]; - const p2 = points[1]; + const [p1, p2] = Array.from(activePointersRef.current.values()); + const midX = (p1.x + p2.x) / 2; const midY = (p1.y + p2.y) / 2; + + const t = transformRef.current; + pinchRef.current.active = true; pinchRef.current.startDist = Math.hypot(p1.x - p2.x, p1.y - p2.y); - pinchRef.current.startScale = transform.scale; - pinchRef.current.startTx = transform.tx; - pinchRef.current.startTy = transform.ty; + pinchRef.current.startScale = t.scale; + pinchRef.current.startTx = t.tx; + pinchRef.current.startTy = t.ty; - // content coordinate under the pinch midpoint at start - pinchRef.current.startCx = (midX - transform.tx) / transform.scale; - pinchRef.current.startCy = (midY - transform.ty) / transform.scale; + pinchRef.current.startCx = (midX - t.tx) / t.scale; + pinchRef.current.startCy = (midY - t.ty) / t.scale; - // don't treat pinch as a tap pointerState.current.moved = true; } try { viewportRef.current.setPointerCapture(e.pointerId); } catch {} + viewportRef.current.classList.add(styles.grabbing); }; - const onPointerMove = (e) => { + const onPointerMove = useCallback((e) => { if (!pointerState.current.dragging) return; - // Keep active pointer positions updated if (activePointersRef.current.has(e.pointerId)) { - activePointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); + activePointersRef.current.set(e.pointerId, { + x: e.clientX, + y: e.clientY, + }); } - // Pinch zoom: when two pointers are active if (pinchRef.current.active && activePointersRef.current.size === 2) { - const points = Array.from(activePointersRef.current.values()); - const p1 = points[0]; - const p2 = points[1]; + const [p1, p2] = Array.from(activePointersRef.current.values()); + const midX = (p1.x + p2.x) / 2; const midY = (p1.y + p2.y) / 2; + const dist = Math.hypot(p1.x - p2.x, p1.y - p2.y); if (pinchRef.current.startDist > 0) { const scaleFactor = dist / pinchRef.current.startDist; + const nextScale = clamp(pinchRef.current.startScale * scaleFactor, 0.25, 6); - // Keep the content point under the pinch midpoint anchored under the fingers. const nextTx = midX - pinchRef.current.startCx * nextScale; const nextTy = midY - pinchRef.current.startCy * nextScale; - setTransform((t) => ({ ...t, scale: nextScale, tx: nextTx, ty: nextTy })); + updateTransform((t) => { + t.scale = nextScale; + t.tx = nextTx; + t.ty = nextTy; + }); } + return; } - // Pan only when exactly one pointer is active if (activePointersRef.current.size !== 1) return; const dx = e.clientX - pointerState.current.lastX; const dy = e.clientY - pointerState.current.lastY; - // mark as moved if threshold exceeded if (!pointerState.current.moved && Math.hypot(dx, dy) > 5) { pointerState.current.moved = true; } @@ -129,24 +191,24 @@ export default function Editor() { pointerState.current.lastY = e.clientY; if (pointerState.current.moved) { - setTransform((t) => ({ ...t, tx: t.tx + dx, ty: t.ty + dy })); + updateTransform((t) => { + t.tx += dx; + t.ty += dy; + }); } - }; + }, []); const onPointerUp = (e) => { const moved = pointerState.current.moved; - // Remove pointer from active set first activePointersRef.current.delete(e.pointerId); if (activePointersRef.current.size < 2) { pinchRef.current.active = false; } - // Keep dragging true if another pointer remains down pointerState.current.dragging = activePointersRef.current.size > 0; - // If exactly one pointer remains, reset lastX/lastY so pan can continue smoothly if (activePointersRef.current.size === 1) { const remaining = Array.from(activePointersRef.current.values())[0]; pointerState.current.lastX = remaining.x; @@ -156,27 +218,137 @@ export default function Editor() { try { viewportRef.current.releasePointerCapture(e.pointerId); } catch {} + viewportRef.current?.classList.remove(styles.grabbing); - // Only treat as click if user didn't drag if (!moved && isColorMode) { - // Find nearest shape inside SVG const svgRoot = innerRef.current?.querySelector("svg"); if (!svgRoot) return; const shape = document.elementFromPoint(e.clientX, e.clientY)?.closest(SHAPE_SELECTOR); - if (!shape || !svgRoot.contains(shape)) return; - // Adds original colour on tap/click + if (!shape || !svgRoot.contains(shape) || shape.classList.contains(styles.coloredRegion)) return; + shape.classList.add(styles.coloredRegion); + + // Record history snapshot + const currentShapes = Array.from(svgRoot.querySelectorAll(`.${styles.coloredRegion}`)).map((el) => el.dataset.id); + + const newHistory = history.slice(0, historyIndex + 1); // drop redo steps + newHistory.push(currentShapes); + + setHistory(newHistory); + setHistoryIndex(newHistory.length - 1); } }; - const cardClass = isColorMode ? styles.colorMode : styles.previewMode; - const transformStyle = { - transform: `translate(${transform.tx}px, ${transform.ty}px) scale(${transform.scale})`, + const undo = useCallback(() => { + if (historyIndex <= 0) return; + + const newIndex = historyIndex - 1; + setHistoryIndex(newIndex); + restoreHistory(history[newIndex]); + }, [history, historyIndex]); + + const redo = useCallback(() => { + if (historyIndex >= history.length - 1) return; + + const newIndex = historyIndex + 1; + setHistoryIndex(newIndex); + restoreHistory(history[newIndex]); + }, [history, historyIndex]); + + const restoreHistory = (snapshot) => { + const svgRoot = innerRef.current?.querySelector("svg"); + if (!svgRoot) return; + + // Remove all colored classes + svgRoot.querySelectorAll(`.${styles.coloredRegion}`).forEach((el) => { + el.classList.remove(styles.coloredRegion); + }); + + // Apply snapshot + snapshot.forEach((id) => { + const el = svgRoot.querySelector(`[data-id="${id}"]`); + if (el) el.classList.add(styles.coloredRegion); + }); }; + const cardClass = isColorMode ? styles.colorMode : styles.previewMode; + + // useEffect for pointer moves with RAF batching and raw/fallback support + useEffect(() => { + const el = viewportRef.current; + if (!el) return; + + const queuedEvent = { e: null }; + let ticking = false; + + const handlePointerMove = (e) => { + queuedEvent.e = e; + if (!ticking) { + ticking = true; + requestAnimationFrame(() => { + if (queuedEvent.e) { + onPointerMove(queuedEvent.e); + queuedEvent.e = null; + } + ticking = false; + }); + } + }; + + const supportsRawUpdate = typeof window !== "undefined" && "onpointerrawupdate" in window; + const pointerEventType = supportsRawUpdate ? "pointerrawupdate" : "pointermove"; + + el.addEventListener(pointerEventType, handlePointerMove); + + return () => { + el.removeEventListener(pointerEventType, handlePointerMove); + }; + }, [onPointerMove]); + + // Keybindings + useEffect(() => { + const handler = (e) => { + const mod = e.ctrlKey || e.metaKey; + const target = e.target; + const isEditable = target instanceof HTMLElement && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)); + + if (!mod || isEditable) { + return; + } + + const key = e.key.toLowerCase(); + if (key === "z" && !e.shiftKey) { + e.preventDefault(); + undo(); + } else if ((key === "y" && !e.shiftKey) || (key === "z" && e.shiftKey)) { + e.preventDefault(); + redo(); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [historyIndex, history, redo, undo]); + + useEffect(() => { + const svgRoot = innerRef.current?.querySelector("svg"); + if (!svgRoot) return; + + const shapes = svgRoot.querySelectorAll(SHAPE_SELECTOR); + shapes.forEach((shape, i) => { + if (!shape.dataset.id) { + shape.dataset.id = `shape-${i}`; + } + }); + + // Store the empty initial snapshot + setInitialSnapshot([]); + setHistory([[]]); // history starts with empty selection + setHistoryIndex(0); + }, [svgElements]); + if (!svg) { return ( @@ -191,31 +363,54 @@ export default function Editor() { -
- { - setIsColorMode((prev) => !prev); - }} - ariaLabel={`Switch to ${isColorMode ? "preview" : "color"} mode`} - thumbContent={isColorMode ? : } - /> -
+ setModalOpen(true)} + onUndo={undo} + onRedo={redo} + onFullscreen={toggleFullscreen} + /> + + setModalOpen(false)}> +

Reset your progress

+

If you click confirm, all of your progress will be reset and you will start coloring in the image from scratch.

+
+ + +
+
+ +
Click shapes to reveal their original colour.
{ + viewportRef.current = el; + fsRef.current = el; // link fullscreen ref to viewport + }} onPointerDown={onPointerDown} - onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp} > -
+
{svgElements}
-
Click shapes to reveal their original colour. Use mouse wheel to zoom, drag to pan. Switching modes preserves fills.
); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10cf0fc35..7a6ef6174 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -133,6 +133,9 @@ importers: img2num: specifier: workspace:* version: link:../../packages/js + jspdf: + specifier: ^4.2.1 + version: 4.2.1 lucide-react: specifier: ^0.577.0 version: 0.577.0(react@18.3.1) @@ -2841,12 +2844,18 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/pako@2.0.4': + resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} + '@types/prismjs@1.26.5': resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==} '@types/qs@6.14.0': resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} + '@types/raf@3.4.3': + resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==} + '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} @@ -3209,6 +3218,10 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base64-arraybuffer@1.0.2: + resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} + engines: {node: '>= 0.6.0'} + baseline-browser-mapping@2.9.19: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true @@ -3328,6 +3341,10 @@ packages: caniuse-lite@1.0.30001769: resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} + canvg@3.0.11: + resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==} + engines: {node: '>=10.0.0'} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -3666,6 +3683,9 @@ packages: peerDependencies: postcss: ^8.4 + css-line-break@2.1.0: + resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} + css-loader@6.11.0: resolution: {integrity: sha512-CTJ+AEQJjq5NzLga5pE39qdiSV56F8ywCIsqNIRF0r7BDgWsN25aazToqAFg7ZrtA/U016xudB3ffgweORxX7g==} engines: {node: '>= 12.13.0'} @@ -4388,6 +4408,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-png@6.4.0: + resolution: {integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} @@ -4414,6 +4437,9 @@ packages: resolution: {integrity: sha512-u5/sxGfiMfZNtJ3OvQpXcvotFpYkL0n9u9mM2vkui2nGo8b4wvDkJ8gAkYqbA8QpGyFCv3RK0Z+Iv+9veCS9bQ==} engines: {node: '>=0.4.0'} + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} @@ -4789,6 +4815,10 @@ packages: webpack: optional: true + html2canvas@1.4.1: + resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} + engines: {node: '>=8.0.0'} + htmlparser2@10.1.0: resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} @@ -4914,6 +4944,9 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + iobuffer@5.4.0: + resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -5159,6 +5192,9 @@ packages: resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} engines: {'0': node >= 0.2.0} + jspdf@4.2.1: + resolution: {integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==} + katex@0.16.28: resolution: {integrity: sha512-YHzO7721WbmAL6Ov1uzN/l5mY5WWWhJBSW+jq4tkfZfsxmo1hu6frS0EOswvjBUnWE6NtjEs48SFn5CQESRLZg==} hasBin: true @@ -5936,6 +5972,9 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + param-case@3.0.4: resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} @@ -6026,6 +6065,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -6544,6 +6586,9 @@ packages: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} + raf@3.4.1: + resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} + randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} @@ -6710,6 +6755,9 @@ packages: regenerate@1.4.2: resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + regexpu-core@6.4.0: resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==} engines: {node: '>=4'} @@ -6818,6 +6866,10 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rgbcolor@1.0.1: + resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==} + engines: {node: '>= 0.8.15'} + rimraf@6.1.3: resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} engines: {node: 20 || >=22} @@ -7076,6 +7128,10 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + stackblur-canvas@2.7.0: + resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==} + engines: {node: '>=0.1.14'} + standard-version@9.5.0: resolution: {integrity: sha512-3zWJ/mmZQsOaO+fOlsa0+QK90pwhNd042qEcw6hKFNoLFs7peGyvPffpEBbK/DSGPbyOvli0mUIFv5A4qTjh2Q==} engines: {node: '>=10'} @@ -7183,6 +7239,10 @@ packages: svg-parser@2.0.4: resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==} + svg-pathdata@6.0.3: + resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==} + engines: {node: '>=12.0.0'} + svgo@3.3.2: resolution: {integrity: sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==} engines: {node: '>=14.0.0'} @@ -7220,6 +7280,9 @@ packages: resolution: {integrity: sha512-wiBrwC1EhBelW12Zy26JeOUkQ5mRu+5o8rpsJk5+2t+Y5vE7e842qtZDQ2g1NpX/29HdyFeJ4nSIhI47ENSxlQ==} engines: {node: '>=0.10'} + text-segmentation@1.0.3: + resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} + thingies@2.5.0: resolution: {integrity: sha512-s+2Bwztg6PhWUD7XMfeYm5qliDdSiZm7M7n8KjTkIsm3l/2lgVRc2/Gx/v+ZX8lT4FMA+i8aQvhcWylldc+ZNw==} engines: {node: '>=10.18'} @@ -7495,6 +7558,9 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + utrie@1.0.2: + resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} + uuid@11.1.0: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true @@ -11184,10 +11250,15 @@ snapshots: '@types/normalize-package-data@2.4.4': {} + '@types/pako@2.0.4': {} + '@types/prismjs@1.26.5': {} '@types/qs@6.14.0': {} + '@types/raf@3.4.3': + optional: true + '@types/range-parser@1.2.7': {} '@types/react-dom@19.2.3(@types/react@19.2.13)': @@ -11619,6 +11690,9 @@ snapshots: balanced-match@4.0.4: {} + base64-arraybuffer@1.0.2: + optional: true + baseline-browser-mapping@2.9.19: {} batch@0.6.1: {} @@ -11767,6 +11841,18 @@ snapshots: caniuse-lite@1.0.30001769: {} + canvg@3.0.11: + dependencies: + '@babel/runtime': 7.29.2 + '@types/raf': 3.4.3 + core-js: 3.48.0 + raf: 3.4.1 + regenerator-runtime: 0.13.11 + rgbcolor: 1.0.1 + stackblur-canvas: 2.7.0 + svg-pathdata: 6.0.3 + optional: true + ccount@2.0.1: {} chai@6.2.2: {} @@ -12153,6 +12239,11 @@ snapshots: postcss-selector-parser: 7.1.1 postcss-value-parser: 4.2.0 + css-line-break@2.1.0: + dependencies: + utrie: 1.0.2 + optional: true + css-loader@6.11.0(webpack@5.105.0): dependencies: icss-utils: 5.1.0(postcss@8.5.8) @@ -12973,6 +13064,12 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-png@6.4.0: + dependencies: + '@types/pako': 2.0.4 + iobuffer: 5.4.0 + pako: 2.1.0 + fast-uri@3.1.0: {} fastq@1.20.1: @@ -12995,6 +13092,8 @@ snapshots: dependencies: xml-js: 1.6.11 + fflate@0.8.2: {} + figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -13482,6 +13581,12 @@ snapshots: optionalDependencies: webpack: 5.105.0 + html2canvas@1.4.1: + dependencies: + css-line-break: 2.1.0 + text-segmentation: 1.0.3 + optional: true + htmlparser2@10.1.0: dependencies: domelementtype: 2.3.0 @@ -13606,6 +13711,8 @@ snapshots: dependencies: loose-envify: 1.4.0 + iobuffer@5.4.0: {} + ipaddr.js@1.9.1: {} ipaddr.js@2.3.0: {} @@ -13820,6 +13927,17 @@ snapshots: jsonparse@1.3.1: {} + jspdf@4.2.1: + dependencies: + '@babel/runtime': 7.29.2 + fast-png: 6.4.0 + fflate: 0.8.2 + optionalDependencies: + canvg: 3.0.11 + core-js: 3.48.0 + dompurify: 3.3.1 + html2canvas: 1.4.1 + katex@0.16.28: dependencies: commander: 8.3.0 @@ -14890,6 +15008,8 @@ snapshots: package-manager-detector@1.6.0: {} + pako@2.1.0: {} + param-case@3.0.4: dependencies: dot-case: 3.0.4 @@ -14980,6 +15100,9 @@ snapshots: pathe@2.0.3: {} + performance-now@2.1.0: + optional: true + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -15530,6 +15653,11 @@ snapshots: quick-lru@5.1.1: {} + raf@3.4.1: + dependencies: + performance-now: 2.1.0 + optional: true + randombytes@2.1.0: dependencies: safe-buffer: 5.2.1 @@ -15584,7 +15712,7 @@ snapshots: react-loadable-ssr-addon-v5-slorber@1.0.1(@docusaurus/react-loadable@6.0.0(react@18.3.1))(webpack@5.105.0): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 react-loadable: '@docusaurus/react-loadable@6.0.0(react@18.3.1)' webpack: 5.105.0 @@ -15592,13 +15720,13 @@ snapshots: react-router-config@5.1.1(react-router@5.3.4(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 react: 18.3.1 react-router: 5.3.4(react@18.3.1) react-router-dom@5.3.4(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 history: 4.10.1 loose-envify: 1.4.0 prop-types: 15.8.1 @@ -15615,7 +15743,7 @@ snapshots: react-router@5.3.4(react@18.3.1): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 history: 4.10.1 hoist-non-react-statics: 3.3.2 loose-envify: 1.4.0 @@ -15737,6 +15865,9 @@ snapshots: regenerate@1.4.2: {} + regenerator-runtime@0.13.11: + optional: true + regexpu-core@6.4.0: dependencies: regenerate: 1.4.2 @@ -15900,6 +16031,9 @@ snapshots: reusify@1.1.0: {} + rgbcolor@1.0.1: + optional: true + rimraf@6.1.3: dependencies: glob: 13.0.6 @@ -16277,6 +16411,9 @@ snapshots: stackback@0.0.2: {} + stackblur-canvas@2.7.0: + optional: true + standard-version@9.5.0: dependencies: chalk: 2.4.2 @@ -16387,6 +16524,9 @@ snapshots: svg-parser@2.0.4: {} + svg-pathdata@6.0.3: + optional: true + svgo@3.3.2: dependencies: '@trysound/sax': 0.2.0 @@ -16419,6 +16559,11 @@ snapshots: text-extensions@1.9.0: {} + text-segmentation@1.0.3: + dependencies: + utrie: 1.0.2 + optional: true + thingies@2.5.0(tslib@2.8.1): dependencies: tslib: 2.8.1 @@ -16664,6 +16809,11 @@ snapshots: utils-merge@1.0.1: {} + utrie@1.0.2: + dependencies: + base64-arraybuffer: 1.0.2 + optional: true + uuid@11.1.0: {} uuid@8.3.2: {}