@xom11/whiteboard 0.6.1 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  "use client";
2
+ require('./index.css');
2
3
  'use strict';
3
4
 
4
5
  var excalidraw = require('@excalidraw/excalidraw');
@@ -6,16 +7,20 @@ var jsxRuntime = require('react/jsx-runtime');
6
7
  var dynamic = require('next/dynamic');
7
8
  var react = require('react');
8
9
  var reactDom = require('react-dom');
9
- var JXG = require('jsxgraph');
10
10
  require('@excalidraw/excalidraw/index.css');
11
11
 
12
12
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
13
13
 
14
14
  var dynamic__default = /*#__PURE__*/_interopDefault(dynamic);
15
- var JXG__default = /*#__PURE__*/_interopDefault(JXG);
16
15
 
17
16
  var __defProp = Object.defineProperty;
18
17
  var __getOwnPropNames = Object.getOwnPropertyNames;
18
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
19
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
20
+ }) : x)(function(x) {
21
+ if (typeof require !== "undefined") return require.apply(this, arguments);
22
+ throw Error('Dynamic require of "' + x + '" is not supported');
23
+ });
19
24
  var __esm = (fn, res) => function __init() {
20
25
  return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
21
26
  };
@@ -293,6 +298,22 @@ var GROUP_LABELS = {
293
298
  edit: "Ch\u1EC9nh s\u1EEDa",
294
299
  transform: "Ph\xE9p bi\u1EBFn h\xECnh"
295
300
  };
301
+ var GROUP_ORDER = [
302
+ "move",
303
+ "point",
304
+ "line",
305
+ "construct",
306
+ "polygon",
307
+ "circle",
308
+ "measure",
309
+ "edit",
310
+ "transform"
311
+ ];
312
+ var A_CODE = "A".charCodeAt(0);
313
+ function letterForGroup(g) {
314
+ const idx = GROUP_ORDER.indexOf(g);
315
+ return idx >= 0 ? String.fromCharCode(A_CODE + idx) : "";
316
+ }
296
317
  function objKind(obj) {
297
318
  if (!obj) return "other";
298
319
  const e = (obj.elType || obj.type || "").toString().toLowerCase();
@@ -678,9 +699,9 @@ function handleMove(ctx, e) {
678
699
  if (!ctx.boardRef.current || !ctx.phantomRef.current) return;
679
700
  try {
680
701
  const coords = ctx.boardRef.current.getUsrCoordsOfMouse(e);
681
- const JXG3 = ctx.jxgRef.current;
682
- if (!JXG3) return;
683
- ctx.phantomRef.current.setPositionDirectly(JXG3.COORDS_BY_USER, [coords[0], coords[1]]);
702
+ const JXG = ctx.jxgRef.current;
703
+ if (!JXG) return;
704
+ ctx.phantomRef.current.setPositionDirectly(JXG.COORDS_BY_USER, [coords[0], coords[1]]);
684
705
  ctx.boardRef.current.update();
685
706
  } catch {
686
707
  }
@@ -1487,11 +1508,11 @@ var JSXGraphMiniBoard = ({ onReady, initialState, isDark }) => {
1487
1508
  if (typeof window === "undefined" || !containerRef.current) return;
1488
1509
  let cancelled = false;
1489
1510
  (async () => {
1490
- const JXG3 = (await import('jsxgraph')).default;
1511
+ const JXG = (await import('jsxgraph')).default;
1491
1512
  if (cancelled || !containerRef.current) return;
1492
- jxgRef.current = JXG3;
1513
+ jxgRef.current = JXG;
1493
1514
  try {
1494
- const opts = JXG3.Options;
1515
+ const opts = JXG.Options;
1495
1516
  if (opts) {
1496
1517
  opts.text = opts.text || {};
1497
1518
  opts.text.display = "internal";
@@ -1505,7 +1526,7 @@ var JSXGraphMiniBoard = ({ onReady, initialState, isDark }) => {
1505
1526
  }
1506
1527
  } catch {
1507
1528
  }
1508
- const board = JXG3.JSXGraph.initBoard(containerId, {
1529
+ const board = JXG.JSXGraph.initBoard(containerId, {
1509
1530
  boundingbox: initialState?.bbox ?? [-10, 10, 10, -10],
1510
1531
  axis: false,
1511
1532
  // We manage axis manually via toggle for clean default
@@ -1685,7 +1706,24 @@ var JSXGraphMiniBoard = ({ onReady, initialState, isDark }) => {
1685
1706
  });
1686
1707
  onReady({
1687
1708
  getContainer: () => containerRef.current,
1688
- getCreationLog: () => [...creationLogRef.current],
1709
+ // Sync toạ độ live của free point về log trước khi trả ra. JSXGraph
1710
+ // cho phép drag free point (args=[x,y] không có ref), việc drag chỉ
1711
+ // cập nhật obj.X()/Y() trên board chứ không đụng log → re-edit + Chèn
1712
+ // sẽ serialize toạ độ cũ → SVG không đổi → fileId trùng → user thấy
1713
+ // "k thay đổi". Line/segment/circle/polygon tham chiếu point qua id
1714
+ // nên auto-update theo.
1715
+ getCreationLog: () => creationLogRef.current.map((e) => {
1716
+ if (e.type !== "point") return { ...e };
1717
+ const args = e.args;
1718
+ if (!Array.isArray(args) || args.length !== 2) return { ...e };
1719
+ if (typeof args[0] !== "number" || typeof args[1] !== "number") return { ...e };
1720
+ const obj = objMapRef.current.get(e.id);
1721
+ if (!obj || typeof obj.X !== "function" || typeof obj.Y !== "function") return { ...e };
1722
+ const x = obj.X();
1723
+ const y = obj.Y();
1724
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return { ...e };
1725
+ return { ...e, args: [x, y] };
1726
+ }),
1689
1727
  getBbox: () => boardRef.current ? boardRef.current.getBoundingBox() : [-10, 10, 10, -10],
1690
1728
  getShowAxis: () => showAxisRef.current,
1691
1729
  getShowGrid: () => showGridRef.current,
@@ -1883,8 +1921,140 @@ var JSXGraphMiniBoard = ({ onReady, initialState, isDark }) => {
1883
1921
  }
1884
1922
  );
1885
1923
  };
1924
+ function MobileToolDrawer({
1925
+ title,
1926
+ headerIcon,
1927
+ chips,
1928
+ actions,
1929
+ groups,
1930
+ activeTool,
1931
+ onToolSelect,
1932
+ drawerOpen,
1933
+ onDrawerClose,
1934
+ isDark,
1935
+ testId
1936
+ }) {
1937
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1938
+ drawerOpen && /* @__PURE__ */ jsxRuntime.jsx(
1939
+ "div",
1940
+ {
1941
+ className: "stamp-drawer-backdrop",
1942
+ onPointerDown: onDrawerClose,
1943
+ "aria-hidden": "true"
1944
+ }
1945
+ ),
1946
+ /* @__PURE__ */ jsxRuntime.jsxs(
1947
+ "aside",
1948
+ {
1949
+ role: "complementary",
1950
+ "aria-label": title,
1951
+ "aria-hidden": !drawerOpen ? "true" : void 0,
1952
+ "data-testid": testId,
1953
+ "data-stamp-area": "true",
1954
+ "data-mobile-drawer": "true",
1955
+ "data-geo-mobile": "true",
1956
+ "data-drawer-state": drawerOpen ? "open" : "closed",
1957
+ className: [
1958
+ isDark ? "theme--dark " : "",
1959
+ "stamp-drawer-mobile flex flex-col border-r border-slate-200 bg-white shadow-md"
1960
+ ].join(""),
1961
+ children: [
1962
+ /* @__PURE__ */ jsxRuntime.jsxs("header", { className: "flex items-center justify-between border-b border-slate-200 bg-gradient-to-r from-slate-50 to-white px-4 py-3", children: [
1963
+ /* @__PURE__ */ jsxRuntime.jsxs("h3", { className: "flex items-center gap-2 text-base font-semibold text-slate-800", children: [
1964
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "inline-flex h-7 w-7 items-center justify-center rounded-lg bg-emerald-50 text-emerald-700", children: headerIcon }),
1965
+ title
1966
+ ] }),
1967
+ /* @__PURE__ */ jsxRuntime.jsx(
1968
+ "button",
1969
+ {
1970
+ type: "button",
1971
+ onClick: onDrawerClose,
1972
+ "aria-label": "\u0110\xF3ng ng\u0103n c\xF4ng c\u1EE5",
1973
+ className: "inline-flex h-9 w-9 items-center justify-center rounded-full text-slate-500 transition hover:bg-slate-100 hover:text-slate-800",
1974
+ children: /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
1975
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
1976
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
1977
+ ] })
1978
+ }
1979
+ )
1980
+ ] }),
1981
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "sticky top-0 z-10 flex items-center gap-2 border-b border-slate-200 bg-white/95 px-3 py-2 backdrop-blur", children: [
1982
+ chips.map((c) => /* @__PURE__ */ jsxRuntime.jsxs(
1983
+ "button",
1984
+ {
1985
+ type: "button",
1986
+ role: "switch",
1987
+ "aria-pressed": c.pressed,
1988
+ "aria-label": c.label,
1989
+ "data-testid": c.testId,
1990
+ onClick: () => c.onToggle(!c.pressed),
1991
+ className: "geo-mobile-chip",
1992
+ children: [
1993
+ c.icon,
1994
+ c.label
1995
+ ]
1996
+ },
1997
+ c.label
1998
+ )),
1999
+ actions.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "ml-auto flex items-center gap-1", children: actions.map((a) => /* @__PURE__ */ jsxRuntime.jsx(
2000
+ "button",
2001
+ {
2002
+ type: "button",
2003
+ onClick: a.onClick,
2004
+ disabled: a.disabled,
2005
+ "aria-label": a.label,
2006
+ title: a.title ?? a.label,
2007
+ className: "inline-flex h-9 w-9 items-center justify-center rounded-full text-slate-600 transition hover:bg-slate-100 hover:text-slate-900 disabled:cursor-not-allowed disabled:text-slate-300 disabled:hover:bg-transparent",
2008
+ children: a.icon
2009
+ },
2010
+ a.label
2011
+ )) })
2012
+ ] }),
2013
+ /* @__PURE__ */ jsxRuntime.jsx(
2014
+ "div",
2015
+ {
2016
+ className: "min-h-0 flex-1 overflow-y-auto",
2017
+ style: { paddingBottom: "calc(0.75rem + env(safe-area-inset-bottom))" },
2018
+ children: groups.map((g) => /* @__PURE__ */ jsxRuntime.jsxs("section", { className: "px-3 pt-3 pb-1", children: [
2019
+ /* @__PURE__ */ jsxRuntime.jsxs("h4", { className: "mb-2 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-slate-500", children: [
2020
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "h-1 w-1 rounded-full bg-emerald-500" }),
2021
+ g.groupLabel
2022
+ ] }),
2023
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "grid grid-cols-3 gap-2", children: g.tools.map((t) => {
2024
+ const active = activeTool === t.key;
2025
+ return /* @__PURE__ */ jsxRuntime.jsxs(
2026
+ "button",
2027
+ {
2028
+ type: "button",
2029
+ "aria-label": t.label,
2030
+ "aria-pressed": active,
2031
+ "data-tool": t.key,
2032
+ onClick: () => {
2033
+ onToolSelect(t.key);
2034
+ onDrawerClose();
2035
+ },
2036
+ className: [
2037
+ "flex flex-col items-center justify-center gap-1.5 rounded-2xl px-2 py-3 transition active:scale-95",
2038
+ active ? "geo-mobile-tool-active" : "bg-slate-50 text-slate-700 hover:bg-slate-100"
2039
+ ].join(" "),
2040
+ children: [
2041
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "flex h-6 w-6 items-center justify-center", children: t.icon }),
2042
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-center text-[11px] font-medium leading-tight line-clamp-2", children: t.label })
2043
+ ]
2044
+ },
2045
+ t.key
2046
+ );
2047
+ }) })
2048
+ ] }, g.group))
2049
+ }
2050
+ )
2051
+ ]
2052
+ }
2053
+ )
2054
+ ] });
2055
+ }
1886
2056
  var TOOLTIP_DELAY_MS = 400;
1887
- function Shell({ title, icon, onClose, children, isDark }) {
2057
+ function Shell({ title, icon, onClose, children, isDark, closeLabel = "\u0110\xF3ng" }) {
1888
2058
  return /* @__PURE__ */ jsxRuntime.jsxs(
1889
2059
  "aside",
1890
2060
  {
@@ -1892,7 +2062,10 @@ function Shell({ title, icon, onClose, children, isDark }) {
1892
2062
  "aria-label": title,
1893
2063
  "data-testid": "stamp-left-panel",
1894
2064
  "data-stamp-area": "true",
1895
- className: `${isDark ? "theme--dark " : ""}absolute left-0 top-0 z-30 flex h-full w-60 flex-col border-r border-slate-200 bg-white shadow-md animate-in slide-in-from-left duration-200`,
2065
+ className: [
2066
+ isDark ? "theme--dark " : "",
2067
+ "absolute left-0 top-0 z-30 flex h-full w-60 flex-col border-r border-slate-200 bg-white shadow-md animate-in slide-in-from-left duration-200"
2068
+ ].join(""),
1896
2069
  children: [
1897
2070
  /* @__PURE__ */ jsxRuntime.jsxs("header", { className: "flex items-center justify-between border-b border-slate-200 bg-gradient-to-r from-slate-50 to-white px-3 py-2", children: [
1898
2071
  /* @__PURE__ */ jsxRuntime.jsxs("h3", { className: "flex items-center gap-2 text-sm font-semibold text-slate-800", children: [
@@ -1903,12 +2076,9 @@ function Shell({ title, icon, onClose, children, isDark }) {
1903
2076
  "button",
1904
2077
  {
1905
2078
  onClick: onClose,
1906
- "aria-label": "\u0110\xF3ng",
2079
+ "aria-label": closeLabel,
1907
2080
  className: "rounded p-1 text-slate-500 transition hover:bg-slate-100 hover:text-slate-800",
1908
- children: /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
1909
- /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
1910
- /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
1911
- ] })
2081
+ children: /* @__PURE__ */ jsxRuntime.jsx(CloseIcon, {})
1912
2082
  }
1913
2083
  )
1914
2084
  ] }),
@@ -1929,24 +2099,36 @@ var GeometryIconHeader = /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "14", h
1929
2099
  /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "20", cy: "20", r: "1.5", fill: "currentColor", stroke: "none" }),
1930
2100
  /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "12", cy: "5", r: "1.5", fill: "currentColor", stroke: "none" })
1931
2101
  ] });
1932
- function LeftPanel({
1933
- activeTool,
1934
- onToolChange,
1935
- showAxis,
1936
- showGrid,
1937
- onShowAxisChange,
1938
- onShowGridChange,
1939
- onUndo,
1940
- canUndo,
1941
- onClose,
1942
- isDark
1943
- }) {
1944
- const grouped = TOOLS.reduce((acc, t) => {
1945
- var _a;
1946
- (acc[_a = t.group] ?? (acc[_a] = [])).push(t);
1947
- return acc;
1948
- }, {});
1949
- const groupKeys = Object.keys(grouped);
2102
+ function CloseIcon() {
2103
+ return /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
2104
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
2105
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
2106
+ ] });
2107
+ }
2108
+ function UndoIcon() {
2109
+ return /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
2110
+ /* @__PURE__ */ jsxRuntime.jsx("polyline", { points: "3 7 3 13 9 13" }),
2111
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3.51 13a9 9 0 1 0 2.13-9.36L3 7" })
2112
+ ] });
2113
+ }
2114
+ function AxisIcon() {
2115
+ return /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", children: [
2116
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "4", y1: "20", x2: "20", y2: "20" }),
2117
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "4", y1: "20", x2: "4", y2: "4" }),
2118
+ /* @__PURE__ */ jsxRuntime.jsx("polyline", { points: "2 6 4 4 6 6" }),
2119
+ /* @__PURE__ */ jsxRuntime.jsx("polyline", { points: "18 18 20 20 18 22" })
2120
+ ] });
2121
+ }
2122
+ function GridIcon() {
2123
+ return /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round", strokeLinejoin: "round", children: [
2124
+ /* @__PURE__ */ jsxRuntime.jsx("rect", { x: "4", y: "4", width: "16", height: "16", rx: "1" }),
2125
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "4", y1: "10", x2: "20", y2: "10" }),
2126
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "4", y1: "16", x2: "20", y2: "16" }),
2127
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "10", y1: "4", x2: "10", y2: "20" }),
2128
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "16", y1: "4", x2: "16", y2: "20" })
2129
+ ] });
2130
+ }
2131
+ function useToolHoverTooltip() {
1950
2132
  const [hover, setHover] = react.useState(null);
1951
2133
  const [portalReady, setPortalReady] = react.useState(false);
1952
2134
  const hoverTimerRef = react.useRef(null);
@@ -1970,6 +2152,23 @@ function LeftPanel({
1970
2152
  }
1971
2153
  setHover(null);
1972
2154
  }, []);
2155
+ return { hover, portalReady, showHover, hideHover };
2156
+ }
2157
+ function DesktopGeometryPanel(props) {
2158
+ const { activeTool, onToolChange, showAxis, showGrid, onShowAxisChange, onShowGridChange, onUndo, canUndo, onClose, isDark, chordGroup } = props;
2159
+ const grouped = react.useMemo(() => {
2160
+ return TOOLS.reduce((acc, t) => {
2161
+ var _a;
2162
+ (acc[_a = t.group] ?? (acc[_a] = [])).push(t);
2163
+ return acc;
2164
+ }, {});
2165
+ }, []);
2166
+ const groupKeys = react.useMemo(
2167
+ () => GROUP_ORDER.filter((g) => grouped[g]),
2168
+ [grouped]
2169
+ );
2170
+ const activeGroupTools = chordGroup ? grouped[chordGroup] ?? null : null;
2171
+ const { hover, portalReady, showHover, hideHover } = useToolHoverTooltip();
1973
2172
  return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1974
2173
  /* @__PURE__ */ jsxRuntime.jsxs(Shell, { title: "H\xECnh h\u1ECDc", icon: GeometryIconHeader, onClose, isDark, children: [
1975
2174
  /* @__PURE__ */ jsxRuntime.jsx(Section, { label: "B\u1ED1 c\u1EE5c", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-3 text-[11px] text-slate-700", children: [
@@ -2006,36 +2205,95 @@ function LeftPanel({
2006
2205
  title: "Ho\xE0n t\xE1c (Ctrl/Cmd+Z)",
2007
2206
  "aria-label": "Ho\xE0n t\xE1c",
2008
2207
  className: "ml-auto inline-flex items-center justify-center rounded p-1 text-slate-600 transition hover:bg-slate-100 hover:text-slate-900 disabled:cursor-not-allowed disabled:text-slate-300 disabled:hover:bg-transparent",
2009
- children: /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
2010
- /* @__PURE__ */ jsxRuntime.jsx("polyline", { points: "3 7 3 13 9 13" }),
2011
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3.51 13a9 9 0 1 0 2.13-9.36L3 7" })
2012
- ] })
2208
+ children: /* @__PURE__ */ jsxRuntime.jsx(UndoIcon, {})
2013
2209
  }
2014
2210
  )
2015
2211
  ] }) }),
2016
- groupKeys.map((group) => /* @__PURE__ */ jsxRuntime.jsx(Section, { label: GROUP_LABELS[group], children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "grid grid-cols-4 gap-1", children: grouped[group].map((t) => {
2017
- const active = activeTool === t.key;
2018
- return /* @__PURE__ */ jsxRuntime.jsx(
2019
- "button",
2212
+ groupKeys.map((group) => {
2213
+ const isChordActive = chordGroup === group;
2214
+ const dimmed = chordGroup !== null && !isChordActive;
2215
+ return /* @__PURE__ */ jsxRuntime.jsxs(
2216
+ "section",
2020
2217
  {
2021
- type: "button",
2022
- "aria-label": t.label,
2023
- "aria-pressed": active,
2024
- "data-tool": t.key,
2025
- onClick: () => onToolChange(t.key),
2026
- onMouseEnter: (e) => showHover(e.currentTarget, t),
2027
- onMouseLeave: hideHover,
2028
- onFocus: (e) => showHover(e.currentTarget, t),
2029
- onBlur: hideHover,
2218
+ "data-chord-group": group,
2219
+ "data-chord-active": isChordActive ? "true" : "false",
2030
2220
  className: [
2031
- "flex h-8 items-center justify-center rounded-md transition",
2032
- active ? "bg-emerald-600 text-white shadow-sm" : "text-slate-700 hover:bg-slate-100 hover:text-slate-900"
2221
+ "rounded-md transition",
2222
+ isChordActive ? "bg-emerald-50 ring-1 ring-emerald-400 p-1" : "p-0",
2223
+ dimmed ? "opacity-55" : "opacity-100"
2033
2224
  ].join(" "),
2034
- children: t.icon
2225
+ children: [
2226
+ /* @__PURE__ */ jsxRuntime.jsxs("h4", { className: "mb-1.5 flex items-center justify-between text-[10px] font-semibold uppercase tracking-wider text-slate-500", children: [
2227
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: GROUP_LABELS[group] }),
2228
+ /* @__PURE__ */ jsxRuntime.jsx(
2229
+ "span",
2230
+ {
2231
+ "data-testid": `chord-letter-${group}`,
2232
+ className: [
2233
+ "font-mono text-[10px] leading-none transition",
2234
+ isChordActive ? "text-emerald-700 font-bold" : "text-slate-400"
2235
+ ].join(" "),
2236
+ children: letterForGroup(group)
2237
+ }
2238
+ )
2239
+ ] }),
2240
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "grid grid-cols-4 gap-1", children: grouped[group].map((t, i) => {
2241
+ const active = activeTool === t.key;
2242
+ return /* @__PURE__ */ jsxRuntime.jsxs(
2243
+ "button",
2244
+ {
2245
+ type: "button",
2246
+ "aria-label": t.label,
2247
+ "aria-pressed": active,
2248
+ "data-tool": t.key,
2249
+ onClick: () => onToolChange(t.key),
2250
+ onMouseEnter: (e) => showHover(e.currentTarget, t),
2251
+ onMouseLeave: hideHover,
2252
+ onFocus: (e) => showHover(e.currentTarget, t),
2253
+ onBlur: hideHover,
2254
+ className: [
2255
+ "relative flex h-8 items-center justify-center rounded-md transition",
2256
+ active ? "bg-emerald-600 text-white shadow-sm" : "text-slate-700 hover:bg-slate-100 hover:text-slate-900"
2257
+ ].join(" "),
2258
+ children: [
2259
+ t.icon,
2260
+ /* @__PURE__ */ jsxRuntime.jsx(
2261
+ "span",
2262
+ {
2263
+ "data-testid": `chord-num-${t.key}`,
2264
+ className: [
2265
+ "pointer-events-none absolute bottom-0 right-0.5 font-mono text-[9px] leading-none transition",
2266
+ active ? "text-white/70" : isChordActive ? "text-emerald-700 font-bold" : "text-slate-400"
2267
+ ].join(" "),
2268
+ children: i + 1
2269
+ }
2270
+ )
2271
+ ]
2272
+ },
2273
+ t.key
2274
+ );
2275
+ }) })
2276
+ ]
2035
2277
  },
2036
- t.key
2278
+ group
2037
2279
  );
2038
- }) }) }, group))
2280
+ }),
2281
+ chordGroup && activeGroupTools && /* @__PURE__ */ jsxRuntime.jsxs(
2282
+ "div",
2283
+ {
2284
+ "data-testid": "chord-hint",
2285
+ className: "mt-1 rounded border border-emerald-200 bg-emerald-50/60 px-2 py-1 text-[11px] leading-snug text-slate-600",
2286
+ children: [
2287
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "font-mono font-semibold text-emerald-700", children: letterForGroup(chordGroup) }),
2288
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "mx-1 text-slate-400", children: "\u2192" }),
2289
+ activeGroupTools.map((t, i) => /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "mr-2 inline-block", children: [
2290
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "font-mono font-semibold text-emerald-700", children: i + 1 }),
2291
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "ml-1", children: t.label })
2292
+ ] }, t.key)),
2293
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-slate-400", children: "Esc hu\u1EF7" })
2294
+ ]
2295
+ }
2296
+ )
2039
2297
  ] }),
2040
2298
  portalReady && hover && typeof document !== "undefined" ? reactDom.createPortal(
2041
2299
  /* @__PURE__ */ jsxRuntime.jsxs(
@@ -2059,6 +2317,78 @@ function LeftPanel({
2059
2317
  ) : null
2060
2318
  ] });
2061
2319
  }
2320
+ function MobileGeometryPanel(props) {
2321
+ const {
2322
+ activeTool,
2323
+ onToolChange,
2324
+ showAxis,
2325
+ showGrid,
2326
+ onShowAxisChange,
2327
+ onShowGridChange,
2328
+ onUndo,
2329
+ canUndo,
2330
+ isDark,
2331
+ drawerOpen,
2332
+ onDrawerClose
2333
+ } = props;
2334
+ const groups = react.useMemo(() => {
2335
+ const acc = /* @__PURE__ */ new Map();
2336
+ for (const t of TOOLS) {
2337
+ if (!acc.has(t.group)) acc.set(t.group, []);
2338
+ acc.get(t.group).push(t);
2339
+ }
2340
+ return Array.from(acc.entries()).map(([group, tools]) => ({
2341
+ group,
2342
+ groupLabel: GROUP_LABELS[group],
2343
+ tools: tools.map((t) => ({ key: t.key, label: t.label, icon: t.icon }))
2344
+ }));
2345
+ }, []);
2346
+ return /* @__PURE__ */ jsxRuntime.jsx(
2347
+ MobileToolDrawer,
2348
+ {
2349
+ title: "H\xECnh h\u1ECDc",
2350
+ headerIcon: GeometryIconHeader,
2351
+ testId: "stamp-left-panel",
2352
+ isDark,
2353
+ drawerOpen: !!drawerOpen,
2354
+ onDrawerClose: () => onDrawerClose?.(),
2355
+ chips: [
2356
+ {
2357
+ label: "Tr\u1EE5c",
2358
+ icon: /* @__PURE__ */ jsxRuntime.jsx(AxisIcon, {}),
2359
+ pressed: showAxis,
2360
+ onToggle: onShowAxisChange,
2361
+ testId: "toggle-axis"
2362
+ },
2363
+ {
2364
+ label: "L\u01B0\u1EDBi",
2365
+ icon: /* @__PURE__ */ jsxRuntime.jsx(GridIcon, {}),
2366
+ pressed: showGrid,
2367
+ onToggle: onShowGridChange,
2368
+ testId: "toggle-grid"
2369
+ }
2370
+ ],
2371
+ actions: [
2372
+ {
2373
+ label: "Ho\xE0n t\xE1c",
2374
+ title: "Ho\xE0n t\xE1c (Ctrl/Cmd+Z)",
2375
+ icon: /* @__PURE__ */ jsxRuntime.jsx(UndoIcon, {}),
2376
+ onClick: onUndo,
2377
+ disabled: !canUndo
2378
+ }
2379
+ ],
2380
+ groups,
2381
+ activeTool,
2382
+ onToolSelect: onToolChange
2383
+ }
2384
+ );
2385
+ }
2386
+ function LeftPanel(props) {
2387
+ if (props.isMobile) {
2388
+ return /* @__PURE__ */ jsxRuntime.jsx(MobileGeometryPanel, { ...props });
2389
+ }
2390
+ return /* @__PURE__ */ jsxRuntime.jsx(DesktopGeometryPanel, { ...props });
2391
+ }
2062
2392
 
2063
2393
  // src/stamps/geometry-2d/serialize.ts
2064
2394
  function serializeBoard(board, log, options = {}) {
@@ -2137,9 +2467,9 @@ function renderGeometryToSvg(boardContainer) {
2137
2467
  async function renderGeometrySvgFromState(jsonState) {
2138
2468
  const parsed = JSON.parse(jsonState);
2139
2469
  const palette = paletteFor(false);
2140
- const JXG3 = (await import('jsxgraph')).default;
2470
+ const JXG = (await import('jsxgraph')).default;
2141
2471
  try {
2142
- const opts = JXG3.Options;
2472
+ const opts = JXG.Options;
2143
2473
  if (opts) {
2144
2474
  opts.text = opts.text || {};
2145
2475
  opts.text.display = "internal";
@@ -2164,7 +2494,7 @@ async function renderGeometrySvgFromState(jsonState) {
2164
2494
  document.body.appendChild(container);
2165
2495
  let board = null;
2166
2496
  try {
2167
- board = JXG3.JSXGraph.initBoard(containerId, {
2497
+ board = JXG.JSXGraph.initBoard(containerId, {
2168
2498
  boundingbox: parsed.bbox,
2169
2499
  axis: !!parsed.showAxis,
2170
2500
  grid: !!parsed.showGrid,
@@ -2177,7 +2507,7 @@ async function renderGeometrySvgFromState(jsonState) {
2177
2507
  return renderGeometryToSvg(container);
2178
2508
  } finally {
2179
2509
  try {
2180
- if (board) JXG3.JSXGraph.freeBoard(board);
2510
+ if (board) JXG.JSXGraph.freeBoard(board);
2181
2511
  } catch {
2182
2512
  }
2183
2513
  if (container.parentNode) container.parentNode.removeChild(container);
@@ -2203,6 +2533,38 @@ var STROKE_PALETTE = [
2203
2533
  "#868e96"
2204
2534
  // gray
2205
2535
  ];
2536
+ var MOBILE_QUERY = "(max-width: 768px)";
2537
+ var NO_HOVER_QUERY = "(hover: none)";
2538
+ function readMatch(query) {
2539
+ if (typeof window === "undefined" || !window.matchMedia) return false;
2540
+ try {
2541
+ return window.matchMedia(query).matches;
2542
+ } catch {
2543
+ return false;
2544
+ }
2545
+ }
2546
+ function useIsMobile() {
2547
+ const [state, setState] = react.useState(() => ({
2548
+ isMobile: readMatch(MOBILE_QUERY),
2549
+ isTouchOnly: readMatch(NO_HOVER_QUERY)
2550
+ }));
2551
+ react.useEffect(() => {
2552
+ if (typeof window === "undefined" || !window.matchMedia) return;
2553
+ const mql = window.matchMedia(MOBILE_QUERY);
2554
+ const tql = window.matchMedia(NO_HOVER_QUERY);
2555
+ const update = () => {
2556
+ setState({ isMobile: mql.matches, isTouchOnly: tql.matches });
2557
+ };
2558
+ update();
2559
+ mql.addEventListener("change", update);
2560
+ tql.addEventListener("change", update);
2561
+ return () => {
2562
+ mql.removeEventListener("change", update);
2563
+ tql.removeEventListener("change", update);
2564
+ };
2565
+ }, []);
2566
+ return state;
2567
+ }
2206
2568
  var DASH_OPTIONS = [
2207
2569
  { value: 0, label: "N\xE9t li\u1EC1n" },
2208
2570
  { value: 2, label: "N\xE9t \u0111\u1EE9t" },
@@ -2261,6 +2623,26 @@ var PropertiesPopover = (props) => {
2261
2623
  const { anchor, onClose, onMutate, isDark, getAllNames } = props;
2262
2624
  const rootRef = react.useRef(null);
2263
2625
  const [section, setSection] = react.useState(null);
2626
+ const { isMobile } = useIsMobile();
2627
+ const [clamped, setClamped] = react.useState(null);
2628
+ react.useLayoutEffect(() => {
2629
+ if (typeof window === "undefined") return;
2630
+ const margin = 8;
2631
+ if (isMobile) {
2632
+ const rect2 = rootRef.current?.getBoundingClientRect();
2633
+ const w2 = rect2?.width ?? 280;
2634
+ const left2 = Math.max(margin, (window.innerWidth - w2) / 2);
2635
+ const top2 = window.innerHeight - (rect2?.height ?? 80) - margin - 12;
2636
+ setClamped({ left: left2, top: Math.max(margin, top2) });
2637
+ return;
2638
+ }
2639
+ const rect = rootRef.current?.getBoundingClientRect();
2640
+ const w = rect?.width ?? 280;
2641
+ const h = rect?.height ?? 80;
2642
+ const left = Math.max(margin, Math.min(anchor.x, window.innerWidth - w - margin));
2643
+ const top = Math.max(margin, Math.min(anchor.y, window.innerHeight - h - margin));
2644
+ setClamped({ left, top });
2645
+ }, [anchor.x, anchor.y, isMobile, section]);
2264
2646
  const initialName = props.kind === "point" ? props.currentName : props.kind === "line" || props.kind === "circle" ? props.currentName : "";
2265
2647
  const [name, setName] = react.useState(initialName);
2266
2648
  react.useEffect(() => {
@@ -2273,14 +2655,14 @@ var PropertiesPopover = (props) => {
2273
2655
  onClose();
2274
2656
  }
2275
2657
  };
2276
- const onMouseDown = (e) => {
2658
+ const onPointerDown = (e) => {
2277
2659
  if (!rootRef.current?.contains(e.target)) onClose();
2278
2660
  };
2279
2661
  document.addEventListener("keydown", onKey);
2280
- document.addEventListener("mousedown", onMouseDown, { capture: true });
2662
+ document.addEventListener("pointerdown", onPointerDown, { capture: true });
2281
2663
  return () => {
2282
2664
  document.removeEventListener("keydown", onKey);
2283
- document.removeEventListener("mousedown", onMouseDown, { capture: true });
2665
+ document.removeEventListener("pointerdown", onPointerDown, { capture: true });
2284
2666
  };
2285
2667
  }, [onClose]);
2286
2668
  const pickColor = (c) => {
@@ -2317,6 +2699,7 @@ var PropertiesPopover = (props) => {
2317
2699
  {
2318
2700
  type: "button",
2319
2701
  "data-section": id,
2702
+ "data-pill-btn": id,
2320
2703
  "aria-label": label,
2321
2704
  "aria-pressed": !!active,
2322
2705
  onClick,
@@ -2335,13 +2718,14 @@ var PropertiesPopover = (props) => {
2335
2718
  }
2336
2719
  );
2337
2720
  const colorIndicatorTint = react.useMemo(() => currentColor, [currentColor]);
2721
+ const pos = clamped ?? { left: anchor.x, top: anchor.y };
2338
2722
  const node = /* @__PURE__ */ jsxRuntime.jsxs(
2339
2723
  "div",
2340
2724
  {
2341
2725
  ref: rootRef,
2342
2726
  "data-stamp-area": "true",
2343
2727
  className: `${isDark ? "theme--dark " : ""}fixed z-[2147483600] flex flex-col gap-1.5`,
2344
- style: { left: anchor.x, top: anchor.y },
2728
+ style: { left: pos.left, top: pos.top },
2345
2729
  role: "dialog",
2346
2730
  "aria-label": "Thu\u1ED9c t\xEDnh \u0111\u1ED1i t\u01B0\u1EE3ng",
2347
2731
  children: [
@@ -2531,7 +2915,7 @@ var TransformParamPopover = ({ kind, anchor, defaultValue, onConfirm, onCancel,
2531
2915
  return reactDom.createPortal(node, document.body);
2532
2916
  };
2533
2917
  var GeometryEditorPanel = react.forwardRef(
2534
- function GeometryEditorPanel2({ initialState, onInsert, onClose, withLeftPanel = false, onStateChange, isDark }, ref) {
2918
+ function GeometryEditorPanel2({ initialState, onInsert, onClose, withLeftPanel = false, onStateChange, isDark, isMobile = false, onOpenDrawer }, ref) {
2535
2919
  const handleRef = react.useRef(null);
2536
2920
  const [ready, setReady] = react.useState(false);
2537
2921
  const [propsPopover, setPropsPopover] = react.useState(null);
@@ -2593,7 +2977,7 @@ var GeometryEditorPanel = react.forwardRef(
2593
2977
  insert: performInsert,
2594
2978
  hasContent: () => (handleRef.current?.getCreationLog().length ?? 0) > 0
2595
2979
  }), [performInsert]);
2596
- const wrapperStyle = {
2980
+ const wrapperStyle = isMobile ? { position: "fixed", inset: 0, zIndex: 40 } : {
2597
2981
  position: "absolute",
2598
2982
  top: "50%",
2599
2983
  left: withLeftPanel ? "calc(50% + 120px)" : "50%",
@@ -2607,11 +2991,30 @@ var GeometryEditorPanel = react.forwardRef(
2607
2991
  "aria-label": "D\u1EF1ng h\xECnh h\u1ECDc",
2608
2992
  "data-testid": "geometry-editor-panel",
2609
2993
  "data-stamp-area": "true",
2994
+ "data-mobile-editor": isMobile ? "true" : void 0,
2610
2995
  style: wrapperStyle,
2611
- className: `${isDark ? "theme--dark " : ""}flex h-[540px] max-h-[85vh] w-[640px] max-w-[calc(100vw-280px)] flex-col overflow-hidden rounded-lg border border-slate-300 bg-white shadow-2xl ring-1 ring-black/5`,
2996
+ className: [
2997
+ isDark ? "theme--dark " : "",
2998
+ "flex flex-col overflow-hidden bg-white",
2999
+ isMobile ? "h-full w-full" : "h-[540px] max-h-[85vh] w-[640px] max-w-[calc(100vw-280px)] rounded-lg border border-slate-300 shadow-2xl ring-1 ring-black/5"
3000
+ ].join(" "),
2612
3001
  children: [
2613
- /* @__PURE__ */ jsxRuntime.jsxs("header", { className: "flex items-center justify-between border-b border-slate-200 bg-gradient-to-r from-emerald-600 to-teal-600 px-3 py-2 text-white", children: [
2614
- /* @__PURE__ */ jsxRuntime.jsxs("h3", { className: "flex items-center gap-2 text-sm font-semibold", children: [
3002
+ /* @__PURE__ */ jsxRuntime.jsxs("header", { className: "flex items-center gap-2 border-b border-slate-200 bg-gradient-to-r from-emerald-600 to-teal-600 px-3 py-2 text-white", children: [
3003
+ isMobile && /* @__PURE__ */ jsxRuntime.jsx(
3004
+ "button",
3005
+ {
3006
+ type: "button",
3007
+ onClick: onOpenDrawer,
3008
+ "aria-label": "M\u1EDF ng\u0103n c\xF4ng c\u1EE5",
3009
+ className: "-ml-1 inline-flex h-10 w-10 items-center justify-center rounded transition hover:bg-white/15",
3010
+ children: /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
3011
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "4", y1: "6", x2: "20", y2: "6" }),
3012
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "4", y1: "12", x2: "20", y2: "12" }),
3013
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "4", y1: "18", x2: "20", y2: "18" })
3014
+ ] })
3015
+ }
3016
+ ),
3017
+ /* @__PURE__ */ jsxRuntime.jsxs("h3", { className: "flex flex-1 items-center gap-2 text-sm font-semibold", children: [
2615
3018
  /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
2616
3019
  /* @__PURE__ */ jsxRuntime.jsx("polygon", { points: "3,18 12,3 21,18" }),
2617
3020
  /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "12", cy: "3", r: "1.5", fill: "currentColor" }),
@@ -2620,12 +3023,23 @@ var GeometryEditorPanel = react.forwardRef(
2620
3023
  ] }),
2621
3024
  "D\u1EF1ng h\xECnh h\u1ECDc"
2622
3025
  ] }),
2623
- /* @__PURE__ */ jsxRuntime.jsx("button", { onClick: onClose, "aria-label": "\u0110\xF3ng", className: "rounded p-1 transition hover:bg-white/15", children: /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
3026
+ isMobile && /* @__PURE__ */ jsxRuntime.jsx(
3027
+ "button",
3028
+ {
3029
+ type: "button",
3030
+ onClick: handleInsert,
3031
+ disabled: !ready,
3032
+ "data-testid": "geometry-insert-btn-mobile",
3033
+ className: "rounded bg-white/15 px-3 py-1.5 text-xs font-semibold transition hover:bg-white/25 disabled:opacity-50",
3034
+ children: "Ch\xE8n"
3035
+ }
3036
+ ),
3037
+ /* @__PURE__ */ jsxRuntime.jsx("button", { onClick: onClose, "aria-label": "\u0110\xF3ng", className: "inline-flex h-9 w-9 items-center justify-center rounded transition hover:bg-white/15", children: /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
2624
3038
  /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
2625
3039
  /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
2626
3040
  ] }) })
2627
3041
  ] }),
2628
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "min-h-0 flex-1", style: { height: "420px" }, children: /* @__PURE__ */ jsxRuntime.jsx(
3042
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "min-h-0 flex-1", style: isMobile ? void 0 : { height: "420px" }, children: /* @__PURE__ */ jsxRuntime.jsx(
2629
3043
  JSXGraphMiniBoard,
2630
3044
  {
2631
3045
  onReady: handleReady,
@@ -2698,7 +3112,7 @@ var GeometryEditorPanel = react.forwardRef(
2698
3112
  }
2699
3113
  }
2700
3114
  ),
2701
- /* @__PURE__ */ jsxRuntime.jsxs("footer", { className: "flex items-center justify-between border-t border-slate-200 bg-slate-50 px-3 py-2", children: [
3115
+ !isMobile && /* @__PURE__ */ jsxRuntime.jsxs("footer", { className: "flex items-center justify-between border-t border-slate-200 bg-slate-50 px-3 py-2", children: [
2702
3116
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-xs text-slate-500", children: "Ch\u1ECDn c\xF4ng c\u1EE5 b\xEAn tr\xE1i, click tr\xEAn b\u1EA3ng \u0111\u1EC3 d\u1EF1ng h\xECnh." }),
2703
3117
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex gap-2", children: [
2704
3118
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -2726,6 +3140,76 @@ var GeometryEditorPanel = react.forwardRef(
2726
3140
  );
2727
3141
  }
2728
3142
  );
3143
+ var A_CODE2 = "a".charCodeAt(0);
3144
+ function isFieldFocused() {
3145
+ const ae = typeof document !== "undefined" ? document.activeElement : null;
3146
+ return !!(ae && (ae.tagName === "INPUT" || ae.tagName === "TEXTAREA" || ae.isContentEditable));
3147
+ }
3148
+ function useChordShortcut(args) {
3149
+ const { groupOrder, tools, onSelect, enabled } = args;
3150
+ const [chordGroup, setChordGroup] = react.useState(null);
3151
+ const groupOrderRef = react.useRef(groupOrder);
3152
+ const toolsRef = react.useRef(tools);
3153
+ const onSelectRef = react.useRef(onSelect);
3154
+ const chordGroupRef = react.useRef(null);
3155
+ groupOrderRef.current = groupOrder;
3156
+ toolsRef.current = tools;
3157
+ onSelectRef.current = onSelect;
3158
+ const cancel = react.useCallback(() => {
3159
+ chordGroupRef.current = null;
3160
+ setChordGroup(null);
3161
+ }, []);
3162
+ react.useEffect(() => {
3163
+ if (!enabled) return;
3164
+ const setChord = (next) => {
3165
+ chordGroupRef.current = next;
3166
+ setChordGroup(next);
3167
+ };
3168
+ const onKey = (e) => {
3169
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
3170
+ if (isFieldFocused()) return;
3171
+ const key = e.key;
3172
+ const lower = key.length === 1 ? key.toLowerCase() : key;
3173
+ if (key === "Escape") {
3174
+ if (chordGroupRef.current !== null) {
3175
+ e.preventDefault();
3176
+ e.stopPropagation();
3177
+ setChord(null);
3178
+ }
3179
+ return;
3180
+ }
3181
+ if (lower.length === 1 && lower >= "a" && lower <= "z") {
3182
+ const idx = lower.charCodeAt(0) - A_CODE2;
3183
+ if (idx < groupOrderRef.current.length) {
3184
+ e.preventDefault();
3185
+ e.stopPropagation();
3186
+ setChord(groupOrderRef.current[idx]);
3187
+ }
3188
+ return;
3189
+ }
3190
+ if (key >= "1" && key <= "9") {
3191
+ const active = chordGroupRef.current;
3192
+ if (active === null) return;
3193
+ const n = key.charCodeAt(0) - "1".charCodeAt(0);
3194
+ const toolsInGroup = toolsRef.current.filter(
3195
+ (t) => t.group === active
3196
+ );
3197
+ e.preventDefault();
3198
+ e.stopPropagation();
3199
+ if (n < toolsInGroup.length) {
3200
+ onSelectRef.current(toolsInGroup[n].key);
3201
+ }
3202
+ setChord(null);
3203
+ return;
3204
+ }
3205
+ };
3206
+ window.addEventListener("keydown", onKey, { capture: true });
3207
+ return () => {
3208
+ window.removeEventListener("keydown", onKey, { capture: true });
3209
+ };
3210
+ }, [enabled]);
3211
+ return { chordGroup, cancel };
3212
+ }
2729
3213
 
2730
3214
  // src/stamps/shared/svgToImage.ts
2731
3215
  async function hashString(input) {
@@ -2848,6 +3332,14 @@ var GeometryStampHost = react.forwardRef(
2848
3332
  function GeometryStampHost2({ api, editingElement, onClose, isDark }, ref) {
2849
3333
  const panelRef = react.useRef(null);
2850
3334
  const [geomState, setGeomState] = react.useState(INITIAL_GEOM_STATE);
3335
+ const { isMobile } = useIsMobile();
3336
+ const [drawerOpen, setDrawerOpen] = react.useState(false);
3337
+ const { chordGroup } = useChordShortcut({
3338
+ groupOrder: GROUP_ORDER,
3339
+ tools: TOOLS,
3340
+ onSelect: (key) => panelRef.current?.setTool(key),
3341
+ enabled: !isMobile
3342
+ });
2851
3343
  const initialState = react.useMemo(() => {
2852
3344
  if (!editingElement) return null;
2853
3345
  if (!isGeometryCustomData(editingElement.customData)) return null;
@@ -2901,7 +3393,11 @@ var GeometryStampHost = react.forwardRef(
2901
3393
  onUndo: () => panelRef.current?.undo(),
2902
3394
  canUndo: geomState.canUndo,
2903
3395
  onClose,
2904
- isDark
3396
+ isDark,
3397
+ isMobile,
3398
+ drawerOpen,
3399
+ onDrawerClose: () => setDrawerOpen(false),
3400
+ chordGroup
2905
3401
  }
2906
3402
  ),
2907
3403
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -2912,8 +3408,10 @@ var GeometryStampHost = react.forwardRef(
2912
3408
  onInsert: handleInsert,
2913
3409
  onClose,
2914
3410
  onStateChange: setGeomState,
2915
- withLeftPanel: true,
2916
- isDark
3411
+ withLeftPanel: !isMobile,
3412
+ isDark,
3413
+ isMobile,
3414
+ onOpenDrawer: () => setDrawerOpen(true)
2917
3415
  }
2918
3416
  )
2919
3417
  ] });
@@ -2951,38 +3449,58 @@ var geometryStamp = {
2951
3449
  },
2952
3450
  Host: GeometryStampHost
2953
3451
  };
2954
- function Shell2({ title, icon, onClose, children }) {
2955
- return /* @__PURE__ */ jsxRuntime.jsxs(
2956
- "aside",
2957
- {
2958
- role: "complementary",
2959
- "aria-label": title,
2960
- "data-testid": "stamp-left-panel",
2961
- "data-stamp-area": "true",
2962
- className: "absolute left-0 top-0 z-30 flex h-full w-60 flex-col border-r border-slate-200 bg-white shadow-md animate-in slide-in-from-left duration-200",
2963
- children: [
2964
- /* @__PURE__ */ jsxRuntime.jsxs("header", { className: "flex items-center justify-between border-b border-slate-200 bg-gradient-to-r from-slate-50 to-white px-3 py-2", children: [
2965
- /* @__PURE__ */ jsxRuntime.jsxs("h3", { className: "flex items-center gap-2 text-sm font-semibold text-slate-800", children: [
2966
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-base leading-none", children: icon }),
2967
- title
3452
+ function Shell2({ title, icon, onClose, children, isMobile, drawerOpen, onDrawerClose }) {
3453
+ const mobileAttrs = isMobile ? {
3454
+ "data-mobile-drawer": "true",
3455
+ "data-drawer-state": drawerOpen ? "open" : "closed"
3456
+ } : {};
3457
+ const handleHeaderClose = () => {
3458
+ if (isMobile) onDrawerClose?.();
3459
+ else onClose();
3460
+ };
3461
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
3462
+ isMobile && drawerOpen && /* @__PURE__ */ jsxRuntime.jsx(
3463
+ "div",
3464
+ {
3465
+ className: "stamp-drawer-backdrop",
3466
+ onPointerDown: onDrawerClose,
3467
+ "aria-hidden": "true"
3468
+ }
3469
+ ),
3470
+ /* @__PURE__ */ jsxRuntime.jsxs(
3471
+ "aside",
3472
+ {
3473
+ role: "complementary",
3474
+ "aria-label": title,
3475
+ "aria-hidden": isMobile && !drawerOpen ? "true" : void 0,
3476
+ "data-testid": "stamp-left-panel",
3477
+ "data-stamp-area": "true",
3478
+ ...mobileAttrs,
3479
+ className: isMobile ? "stamp-drawer-mobile flex flex-col border-r border-slate-200 bg-white shadow-md" : "absolute left-0 top-0 z-30 flex h-full w-60 flex-col border-r border-slate-200 bg-white shadow-md animate-in slide-in-from-left duration-200",
3480
+ children: [
3481
+ /* @__PURE__ */ jsxRuntime.jsxs("header", { className: "flex items-center justify-between border-b border-slate-200 bg-gradient-to-r from-slate-50 to-white px-3 py-2", children: [
3482
+ /* @__PURE__ */ jsxRuntime.jsxs("h3", { className: "flex items-center gap-2 text-sm font-semibold text-slate-800", children: [
3483
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-base leading-none", children: icon }),
3484
+ title
3485
+ ] }),
3486
+ /* @__PURE__ */ jsxRuntime.jsx(
3487
+ "button",
3488
+ {
3489
+ onClick: handleHeaderClose,
3490
+ "aria-label": isMobile ? "\u0110\xF3ng ng\u0103n c\xF4ng c\u1EE5" : "\u0110\xF3ng",
3491
+ className: "rounded p-1 text-slate-500 transition hover:bg-slate-100 hover:text-slate-800",
3492
+ children: /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
3493
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
3494
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
3495
+ ] })
3496
+ }
3497
+ )
2968
3498
  ] }),
2969
- /* @__PURE__ */ jsxRuntime.jsx(
2970
- "button",
2971
- {
2972
- onClick: onClose,
2973
- "aria-label": "\u0110\xF3ng",
2974
- className: "rounded p-1 text-slate-500 transition hover:bg-slate-100 hover:text-slate-800",
2975
- children: /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
2976
- /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
2977
- /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
2978
- ] })
2979
- }
2980
- )
2981
- ] }),
2982
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-3 space-y-4", children })
2983
- ]
2984
- }
2985
- );
3499
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-3 space-y-4", children })
3500
+ ]
3501
+ }
3502
+ )
3503
+ ] });
2986
3504
  }
2987
3505
  function Section2({ label, children }) {
2988
3506
  return /* @__PURE__ */ jsxRuntime.jsxs("section", { children: [
@@ -3029,65 +3547,80 @@ function LeftPanel2({
3029
3547
  displayMode,
3030
3548
  onDisplayModeChange,
3031
3549
  onInsertSnippet,
3032
- onClose
3550
+ onClose,
3551
+ isMobile,
3552
+ drawerOpen,
3553
+ onDrawerClose
3033
3554
  }) {
3034
- return /* @__PURE__ */ jsxRuntime.jsxs(Shell2, { title: "C\xF4ng th\u1EE9c LaTeX", icon: "\u2211", onClose, children: [
3035
- /* @__PURE__ */ jsxRuntime.jsx(Section2, { label: "Ch\u1EBF \u0111\u1ED9 hi\u1EC3n th\u1ECB", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "grid grid-cols-2 gap-1.5", children: [
3036
- /* @__PURE__ */ jsxRuntime.jsxs(
3037
- "button",
3038
- {
3039
- type: "button",
3040
- onClick: () => onDisplayModeChange(false),
3041
- "aria-pressed": !displayMode,
3042
- className: [
3043
- "rounded-md border px-2 py-1.5 text-xs transition",
3044
- !displayMode ? "border-emerald-500 bg-emerald-50 text-emerald-700 ring-1 ring-emerald-300" : "border-slate-200 bg-white text-slate-700 hover:border-slate-300 hover:bg-slate-50"
3045
- ].join(" "),
3046
- children: [
3047
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "block font-medium", children: "Inline" }),
3048
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "block text-[10px] text-slate-500", children: "$ ... $" })
3049
- ]
3050
- }
3051
- ),
3052
- /* @__PURE__ */ jsxRuntime.jsxs(
3053
- "button",
3054
- {
3055
- type: "button",
3056
- onClick: () => onDisplayModeChange(true),
3057
- "aria-pressed": displayMode,
3058
- className: [
3059
- "rounded-md border px-2 py-1.5 text-xs transition",
3060
- displayMode ? "border-emerald-500 bg-emerald-50 text-emerald-700 ring-1 ring-emerald-300" : "border-slate-200 bg-white text-slate-700 hover:border-slate-300 hover:bg-slate-50"
3061
- ].join(" "),
3062
- children: [
3063
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "block font-medium", children: "Block" }),
3064
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "block text-[10px] text-slate-500", children: "$$ ... $$" })
3065
- ]
3066
- }
3067
- )
3068
- ] }) }),
3069
- SNIPPETS.map((group) => /* @__PURE__ */ jsxRuntime.jsx(Section2, { label: group.group, children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex flex-wrap gap-1", children: group.items.map((s) => /* @__PURE__ */ jsxRuntime.jsx(
3070
- "button",
3071
- {
3072
- type: "button",
3073
- onClick: () => onInsertSnippet(s.snippet),
3074
- title: s.snippet,
3075
- className: "rounded border border-slate-200 bg-white px-2 py-1 text-xs text-slate-700 transition hover:border-emerald-300 hover:bg-emerald-50 hover:text-emerald-700",
3076
- children: s.preview
3077
- },
3078
- s.snippet
3079
- )) }) }, group.group)),
3080
- /* @__PURE__ */ jsxRuntime.jsx(Section2, { label: "Ph\xEDm t\u1EAFt", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-wrap gap-2 text-[11px] text-slate-600", children: [
3081
- /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "inline-flex items-center gap-1", children: [
3082
- /* @__PURE__ */ jsxRuntime.jsx("kbd", { className: "rounded border border-slate-300 bg-slate-50 px-1.5 py-0.5 font-mono", children: "Enter" }),
3083
- "ch\xE8n"
3084
- ] }),
3085
- /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "inline-flex items-center gap-1", children: [
3086
- /* @__PURE__ */ jsxRuntime.jsx("kbd", { className: "rounded border border-slate-300 bg-slate-50 px-1.5 py-0.5 font-mono", children: "Esc" }),
3087
- "\u0111\xF3ng"
3088
- ] })
3089
- ] }) })
3090
- ] });
3555
+ return /* @__PURE__ */ jsxRuntime.jsxs(
3556
+ Shell2,
3557
+ {
3558
+ title: "C\xF4ng th\u1EE9c LaTeX",
3559
+ icon: "\u2211",
3560
+ onClose,
3561
+ isMobile,
3562
+ drawerOpen,
3563
+ onDrawerClose,
3564
+ children: [
3565
+ /* @__PURE__ */ jsxRuntime.jsx(Section2, { label: "Ch\u1EBF \u0111\u1ED9 hi\u1EC3n th\u1ECB", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "grid grid-cols-2 gap-1.5", children: [
3566
+ /* @__PURE__ */ jsxRuntime.jsxs(
3567
+ "button",
3568
+ {
3569
+ type: "button",
3570
+ onClick: () => onDisplayModeChange(false),
3571
+ "aria-pressed": !displayMode,
3572
+ className: [
3573
+ "rounded-md border px-2 py-1.5 text-xs transition",
3574
+ !displayMode ? "border-emerald-500 bg-emerald-50 text-emerald-700 ring-1 ring-emerald-300" : "border-slate-200 bg-white text-slate-700 hover:border-slate-300 hover:bg-slate-50"
3575
+ ].join(" "),
3576
+ children: [
3577
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "block font-medium", children: "Inline" }),
3578
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "block text-[10px] text-slate-500", children: "$ ... $" })
3579
+ ]
3580
+ }
3581
+ ),
3582
+ /* @__PURE__ */ jsxRuntime.jsxs(
3583
+ "button",
3584
+ {
3585
+ type: "button",
3586
+ onClick: () => onDisplayModeChange(true),
3587
+ "aria-pressed": displayMode,
3588
+ className: [
3589
+ "rounded-md border px-2 py-1.5 text-xs transition",
3590
+ displayMode ? "border-emerald-500 bg-emerald-50 text-emerald-700 ring-1 ring-emerald-300" : "border-slate-200 bg-white text-slate-700 hover:border-slate-300 hover:bg-slate-50"
3591
+ ].join(" "),
3592
+ children: [
3593
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "block font-medium", children: "Block" }),
3594
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "block text-[10px] text-slate-500", children: "$$ ... $$" })
3595
+ ]
3596
+ }
3597
+ )
3598
+ ] }) }),
3599
+ SNIPPETS.map((group) => /* @__PURE__ */ jsxRuntime.jsx(Section2, { label: group.group, children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex flex-wrap gap-1", children: group.items.map((s) => /* @__PURE__ */ jsxRuntime.jsx(
3600
+ "button",
3601
+ {
3602
+ type: "button",
3603
+ "data-snippet": s.snippet,
3604
+ onClick: () => onInsertSnippet(s.snippet),
3605
+ title: s.snippet,
3606
+ className: "rounded border border-slate-200 bg-white px-2 py-1 text-xs text-slate-700 transition hover:border-emerald-300 hover:bg-emerald-50 hover:text-emerald-700",
3607
+ children: s.preview
3608
+ },
3609
+ s.snippet
3610
+ )) }) }, group.group)),
3611
+ /* @__PURE__ */ jsxRuntime.jsx(Section2, { label: "Ph\xEDm t\u1EAFt", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-wrap gap-2 text-[11px] text-slate-600", children: [
3612
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "inline-flex items-center gap-1", children: [
3613
+ /* @__PURE__ */ jsxRuntime.jsx("kbd", { className: "rounded border border-slate-300 bg-slate-50 px-1.5 py-0.5 font-mono", children: "Enter" }),
3614
+ "ch\xE8n"
3615
+ ] }),
3616
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "inline-flex items-center gap-1", children: [
3617
+ /* @__PURE__ */ jsxRuntime.jsx("kbd", { className: "rounded border border-slate-300 bg-slate-50 px-1.5 py-0.5 font-mono", children: "Esc" }),
3618
+ "\u0111\xF3ng"
3619
+ ] })
3620
+ ] }) })
3621
+ ]
3622
+ }
3623
+ );
3091
3624
  }
3092
3625
 
3093
3626
  // src/stamps/latex/render.ts
@@ -3139,7 +3672,9 @@ var EditorPopover = react.forwardRef(function EditorPopover2({
3139
3672
  onClose,
3140
3673
  displayMode: controlledDisplayMode,
3141
3674
  onDisplayModeChange,
3142
- withLeftPanel = false
3675
+ withLeftPanel = false,
3676
+ isMobile = false,
3677
+ onOpenDrawer
3143
3678
  }, ref) {
3144
3679
  const [value, setValue] = react.useState(initialValue);
3145
3680
  const [internalDisplayMode] = react.useState(false);
@@ -3210,7 +3745,7 @@ var EditorPopover = react.forwardRef(function EditorPopover2({
3210
3745
  [value, previewSvg, error, displayMode, onInsert]
3211
3746
  );
3212
3747
  const isLegacyPosition = x > 0 || y > 0;
3213
- const wrapperStyle = isLegacyPosition ? { position: "absolute", top: y, left: x, zIndex: 50 } : {
3748
+ const wrapperStyle = isMobile ? { position: "fixed", inset: 0, zIndex: 50 } : isLegacyPosition ? { position: "absolute", top: y, left: x, zIndex: 50 } : {
3214
3749
  position: "absolute",
3215
3750
  top: "50%",
3216
3751
  left: withLeftPanel ? "calc(50% + 120px)" : "50%",
@@ -3222,29 +3757,55 @@ var EditorPopover = react.forwardRef(function EditorPopover2({
3222
3757
  {
3223
3758
  style: wrapperStyle,
3224
3759
  "data-stamp-area": "true",
3225
- className: "w-[420px] max-w-[calc(100vw-280px)] rounded-lg border border-slate-300 bg-white shadow-2xl ring-1 ring-black/5",
3760
+ "data-mobile-editor": isMobile ? "true" : void 0,
3761
+ className: isMobile ? "flex h-full w-full flex-col bg-white" : "w-[420px] max-w-[calc(100vw-280px)] rounded-lg border border-slate-300 bg-white shadow-2xl ring-1 ring-black/5",
3226
3762
  role: "dialog",
3227
3763
  "aria-label": "Nh\u1EADp c\xF4ng th\u1EE9c LaTeX",
3228
3764
  children: [
3229
- /* @__PURE__ */ jsxRuntime.jsxs("header", { className: "flex items-center justify-between rounded-t-lg border-b border-slate-200 bg-gradient-to-r from-indigo-600 to-purple-600 px-3 py-2 text-white", children: [
3230
- /* @__PURE__ */ jsxRuntime.jsxs("h3", { className: "flex items-center gap-2 text-sm font-semibold", children: [
3765
+ /* @__PURE__ */ jsxRuntime.jsxs("header", { className: `flex items-center gap-2 border-b border-slate-200 bg-gradient-to-r from-indigo-600 to-purple-600 px-3 py-2 text-white${isMobile ? "" : " rounded-t-lg"}`, children: [
3766
+ isMobile && /* @__PURE__ */ jsxRuntime.jsx(
3767
+ "button",
3768
+ {
3769
+ type: "button",
3770
+ onClick: onOpenDrawer,
3771
+ "aria-label": "M\u1EDF ng\u0103n snippet",
3772
+ className: "-ml-1 inline-flex h-10 w-10 items-center justify-center rounded transition hover:bg-white/15",
3773
+ children: /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
3774
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "4", y1: "6", x2: "20", y2: "6" }),
3775
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "4", y1: "12", x2: "20", y2: "12" }),
3776
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "4", y1: "18", x2: "20", y2: "18" })
3777
+ ] })
3778
+ }
3779
+ ),
3780
+ /* @__PURE__ */ jsxRuntime.jsxs("h3", { className: "flex flex-1 items-center gap-2 text-sm font-semibold", children: [
3231
3781
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-base leading-none", children: "\u2211" }),
3232
3782
  "C\xF4ng th\u1EE9c LaTeX"
3233
3783
  ] }),
3784
+ isMobile && /* @__PURE__ */ jsxRuntime.jsx(
3785
+ "button",
3786
+ {
3787
+ type: "button",
3788
+ onClick: handleInsert,
3789
+ disabled: !previewSvg || !!error,
3790
+ "data-testid": "latex-insert-btn-mobile",
3791
+ className: "rounded bg-white/15 px-3 py-1.5 text-xs font-semibold transition hover:bg-white/25 disabled:opacity-50",
3792
+ children: "Ch\xE8n"
3793
+ }
3794
+ ),
3234
3795
  /* @__PURE__ */ jsxRuntime.jsx(
3235
3796
  "button",
3236
3797
  {
3237
3798
  onClick: onClose,
3238
3799
  "aria-label": "\u0110\xF3ng",
3239
- className: "rounded p-1 transition hover:bg-white/15",
3240
- children: /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
3800
+ className: "inline-flex h-9 w-9 items-center justify-center rounded transition hover:bg-white/15",
3801
+ children: /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
3241
3802
  /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
3242
3803
  /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
3243
3804
  ] })
3244
3805
  }
3245
3806
  )
3246
3807
  ] }),
3247
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-2 p-3", children: [
3808
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: `space-y-2 p-3${isMobile ? " flex min-h-0 flex-1 flex-col" : ""}`, children: [
3248
3809
  /* @__PURE__ */ jsxRuntime.jsx(
3249
3810
  "input",
3250
3811
  {
@@ -3255,7 +3816,7 @@ var EditorPopover = react.forwardRef(function EditorPopover2({
3255
3816
  onChange: (e) => setValue(e.target.value),
3256
3817
  onKeyDown: handleKeyDown,
3257
3818
  placeholder: "Vd: \\frac{a^2+b^2}{c}",
3258
- className: "w-full rounded border border-slate-300 px-2 py-1.5 font-mono text-sm outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-200",
3819
+ className: `w-full rounded border border-slate-300 px-2 py-1.5 font-mono outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-200${isMobile ? " min-h-[44px] text-base" : " text-sm"}`,
3259
3820
  autoFocus: true
3260
3821
  }
3261
3822
  ),
@@ -3263,7 +3824,8 @@ var EditorPopover = react.forwardRef(function EditorPopover2({
3263
3824
  "div",
3264
3825
  {
3265
3826
  className: [
3266
- "flex min-h-[64px] items-center justify-center rounded border p-3 text-center",
3827
+ "flex items-center justify-center rounded border p-3 text-center",
3828
+ isMobile ? "min-h-0 flex-1 overflow-auto" : "min-h-[64px]",
3267
3829
  error ? "border-rose-300 bg-rose-50 text-rose-700" : "border-slate-200 bg-slate-50"
3268
3830
  ].join(" "),
3269
3831
  children: error ? /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "text-xs", children: [
@@ -3272,7 +3834,7 @@ var EditorPopover = react.forwardRef(function EditorPopover2({
3272
3834
  ] }) : previewSvg ? /* @__PURE__ */ jsxRuntime.jsx("span", { dangerouslySetInnerHTML: { __html: previewSvg } }) : /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-xs text-slate-400", children: "(xem tr\u01B0\u1EDBc)" })
3273
3835
  }
3274
3836
  ),
3275
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center justify-between", children: [
3837
+ !isMobile && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center justify-between", children: [
3276
3838
  /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "text-[11px] text-slate-500", children: [
3277
3839
  displayMode ? "Block" : "Inline",
3278
3840
  " \xB7 Enter \u0111\u1EC3 ch\xE8n"
@@ -3296,6 +3858,10 @@ var EditorPopover = react.forwardRef(function EditorPopover2({
3296
3858
  }
3297
3859
  )
3298
3860
  ] })
3861
+ ] }),
3862
+ isMobile && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "text-center text-[11px] text-slate-500", children: [
3863
+ displayMode ? "Block" : "Inline",
3864
+ " \xB7 B\u1EA5m Ch\xE8n \u1EDF thanh tr\xEAn"
3299
3865
  ] })
3300
3866
  ] })
3301
3867
  ]
@@ -3310,6 +3876,8 @@ function isLatexCustomData(data) {
3310
3876
  var LatexStampHost = react.forwardRef(
3311
3877
  function LatexStampHost2({ api, editingElement, onClose }, ref) {
3312
3878
  const editorRef = react.useRef(null);
3879
+ const { isMobile } = useIsMobile();
3880
+ const [drawerOpen, setDrawerOpen] = react.useState(false);
3313
3881
  const initial = react.useMemo(() => {
3314
3882
  if (editingElement && isLatexCustomData(editingElement.customData)) {
3315
3883
  return {
@@ -3356,7 +3924,10 @@ var LatexStampHost = react.forwardRef(
3356
3924
  displayMode,
3357
3925
  onDisplayModeChange: setDisplayMode,
3358
3926
  onInsertSnippet: (s) => editorRef.current?.insertAtCursor(s),
3359
- onClose
3927
+ onClose,
3928
+ isMobile,
3929
+ drawerOpen,
3930
+ onDrawerClose: () => setDrawerOpen(false)
3360
3931
  }
3361
3932
  ),
3362
3933
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -3370,7 +3941,9 @@ var LatexStampHost = react.forwardRef(
3370
3941
  onDisplayModeChange: setDisplayMode,
3371
3942
  onInsert: handleInsert,
3372
3943
  onClose,
3373
- withLeftPanel: true
3944
+ withLeftPanel: !isMobile,
3945
+ isMobile,
3946
+ onOpenDrawer: () => setDrawerOpen(true)
3374
3947
  }
3375
3948
  )
3376
3949
  ] });
@@ -3849,126 +4422,134 @@ var MiniBoard3D = react.forwardRef(function MiniBoard3D2({ isDark, initialState
3849
4422
  react.useEffect(() => {
3850
4423
  const div = containerRef.current;
3851
4424
  if (!div) return;
3852
- JXG__default.default.Options.text.display = "internal";
3853
- const board = JXG__default.default.JSXGraph.initBoard(div, {
3854
- boundingbox: [-6, 6, 6, -6],
3855
- axis: false,
3856
- showCopyright: false,
3857
- showNavigation: false,
3858
- renderer: "svg"
3859
- });
3860
- boardRef.current = board;
3861
- const initView = initialState?.view ?? DEFAULT_VIEW3D;
3862
- const baseAttrs = VIEW3D_ATTRS(isDark);
3863
- const view = board.create(
3864
- "view3d",
3865
- [
3866
- [-5, -5],
3867
- [10, 10],
4425
+ let cancelled = false;
4426
+ let JXG = null;
4427
+ let board = null;
4428
+ void (async () => {
4429
+ JXG = (await import('jsxgraph')).default;
4430
+ if (cancelled || !containerRef.current) return;
4431
+ JXG.Options.text.display = "internal";
4432
+ board = JXG.JSXGraph.initBoard(div, {
4433
+ boundingbox: [-6, 6, 6, -6],
4434
+ axis: false,
4435
+ showCopyright: false,
4436
+ showNavigation: false,
4437
+ renderer: "svg"
4438
+ });
4439
+ boardRef.current = board;
4440
+ const initView = initialState?.view ?? DEFAULT_VIEW3D;
4441
+ const baseAttrs = VIEW3D_ATTRS(isDark);
4442
+ const view = board.create(
4443
+ "view3d",
3868
4444
  [
3869
- [initView.bbox3D[0], initView.bbox3D[3]],
3870
- [initView.bbox3D[1], initView.bbox3D[4]],
3871
- [initView.bbox3D[2], initView.bbox3D[5]]
3872
- ]
3873
- ],
3874
- {
3875
- ...baseAttrs,
3876
- az: { ...baseAttrs.az, value: initView.azimuth },
3877
- el: { ...baseAttrs.el, value: initView.elevation }
3878
- }
3879
- );
3880
- viewRef.current = view;
3881
- let idCounter = 1;
3882
- const ctx = createHandlerContext({
3883
- view,
3884
- pushLog: (e) => {
3885
- logRef.current.push(e);
3886
- notify();
3887
- },
3888
- objMap: objMapRef.current,
3889
- nextId: () => `obj_${Date.now().toString(36)}_${(idCounter++).toString(36)}`,
3890
- isDark,
3891
- promptCoords: (label) => {
3892
- const raw = window.prompt(`${label}
4445
+ [-5, -5],
4446
+ [10, 10],
4447
+ [
4448
+ [initView.bbox3D[0], initView.bbox3D[3]],
4449
+ [initView.bbox3D[1], initView.bbox3D[4]],
4450
+ [initView.bbox3D[2], initView.bbox3D[5]]
4451
+ ]
4452
+ ],
4453
+ {
4454
+ ...baseAttrs,
4455
+ az: { ...baseAttrs.az, value: initView.azimuth },
4456
+ el: { ...baseAttrs.el, value: initView.elevation }
4457
+ }
4458
+ );
4459
+ viewRef.current = view;
4460
+ let idCounter = 1;
4461
+ const ctx = createHandlerContext({
4462
+ view,
4463
+ pushLog: (e) => {
4464
+ logRef.current.push(e);
4465
+ notify();
4466
+ },
4467
+ objMap: objMapRef.current,
4468
+ nextId: () => `obj_${Date.now().toString(36)}_${(idCounter++).toString(36)}`,
4469
+ isDark,
4470
+ promptCoords: (label) => {
4471
+ const raw = window.prompt(`${label}
3893
4472
  (\u0111\u1ECBnh d\u1EA1ng "x,y,z")`, "0,0,0");
3894
- if (!raw) return null;
3895
- const parts = raw.split(",").map((s) => Number(s.trim()));
3896
- if (parts.length !== 3 || parts.some((n) => !isFinite(n))) return null;
3897
- return { x: parts[0], y: parts[1], z: parts[2] };
3898
- },
3899
- promptNumber: (label) => {
3900
- const raw = window.prompt(label, "1");
3901
- if (raw == null) return null;
3902
- const n = Number(raw);
3903
- return isFinite(n) ? n : null;
3904
- },
3905
- promptText: (label) => {
3906
- const raw = window.prompt(label, "");
3907
- return raw == null ? null : raw;
3908
- },
3909
- notify
3910
- });
3911
- ctxRef.current = ctx;
3912
- function findExistingPointAt(clientX, clientY) {
3913
- const containerRect = div.getBoundingClientRect();
3914
- const localX = clientX - containerRect.left;
3915
- const localY = clientY - containerRect.top;
3916
- const PICK = 18;
3917
- const svg = div.querySelector("svg");
3918
- if (!svg) return void 0;
3919
- for (const [id, obj] of objMapRef.current) {
3920
- const entry = obj;
3921
- if (entry?.elType !== "point3d") continue;
3922
- const sc = entry.element2D?.coords?.scrCoords;
3923
- if (!sc || sc.length < 3) continue;
3924
- const dx = sc[1] - localX;
3925
- const dy = sc[2] - localY;
3926
- if (dx * dx + dy * dy <= PICK * PICK) return id;
3927
- }
3928
- return void 0;
3929
- }
3930
- const handlePointerDown = (e) => {
3931
- const tool = toolRef.current;
3932
- if (tool === "move") return;
3933
- const existingPointId = findExistingPointAt(e.clientX, e.clientY);
3934
- let x3 = 0;
3935
- let y3 = 0;
3936
- const z3 = 0;
3937
- try {
3938
- const board2d = boardRef.current;
3939
- if (board2d?.getUsrCoordsOfMouse) {
3940
- const uc = board2d.getUsrCoordsOfMouse(e);
3941
- if (Array.isArray(uc) && uc.length >= 2) {
3942
- x3 = uc[0];
3943
- y3 = uc[1];
3944
- }
4473
+ if (!raw) return null;
4474
+ const parts = raw.split(",").map((s) => Number(s.trim()));
4475
+ if (parts.length !== 3 || parts.some((n) => !isFinite(n))) return null;
4476
+ return { x: parts[0], y: parts[1], z: parts[2] };
4477
+ },
4478
+ promptNumber: (label) => {
4479
+ const raw = window.prompt(label, "1");
4480
+ if (raw == null) return null;
4481
+ const n = Number(raw);
4482
+ return isFinite(n) ? n : null;
4483
+ },
4484
+ promptText: (label) => {
4485
+ const raw = window.prompt(label, "");
4486
+ return raw == null ? null : raw;
4487
+ },
4488
+ notify
4489
+ });
4490
+ ctxRef.current = ctx;
4491
+ function findExistingPointAt(clientX, clientY) {
4492
+ const containerRect = div.getBoundingClientRect();
4493
+ const localX = clientX - containerRect.left;
4494
+ const localY = clientY - containerRect.top;
4495
+ const PICK = 18;
4496
+ const svg = div.querySelector("svg");
4497
+ if (!svg) return void 0;
4498
+ for (const [id, obj] of objMapRef.current) {
4499
+ const entry = obj;
4500
+ if (entry?.elType !== "point3d") continue;
4501
+ const sc = entry.element2D?.coords?.scrCoords;
4502
+ if (!sc || sc.length < 3) continue;
4503
+ const dx = sc[1] - localX;
4504
+ const dy = sc[2] - localY;
4505
+ if (dx * dx + dy * dy <= PICK * PICK) return id;
3945
4506
  }
3946
- } catch {
4507
+ return void 0;
3947
4508
  }
3948
- const hit = { x3, y3, z3, existingPointId };
3949
- handleToolStep(ctx, tool, hit);
3950
- };
3951
- const svgEl = div.querySelector("svg");
3952
- const targetEl = svgEl ?? div;
3953
- const handlePointerDownEv = (e) => handlePointerDown(e);
3954
- targetEl.addEventListener("pointerdown", handlePointerDownEv);
3955
- pointerHandlerRef.current = { el: targetEl, fn: handlePointerDownEv };
3956
- if (initialState?.elements?.length) {
3957
- const map = objMapRef.current;
3958
- for (const el of initialState.elements) {
3959
- const parents = el.parents.map(
3960
- (p2) => typeof p2 === "string" && p2.startsWith("@id:") ? map.get(p2.slice(4)) : p2
3961
- );
3962
- const obj = view.create(el.type, parents, {
3963
- ...el.attributes,
3964
- id: el.id,
3965
- name: el.label
3966
- });
3967
- map.set(el.id, obj);
3968
- logRef.current.push(el);
4509
+ const handlePointerDown = (e) => {
4510
+ const tool = toolRef.current;
4511
+ if (tool === "move") return;
4512
+ const existingPointId = findExistingPointAt(e.clientX, e.clientY);
4513
+ let x3 = 0;
4514
+ let y3 = 0;
4515
+ const z3 = 0;
4516
+ try {
4517
+ const board2d = boardRef.current;
4518
+ if (board2d?.getUsrCoordsOfMouse) {
4519
+ const uc = board2d.getUsrCoordsOfMouse(e);
4520
+ if (Array.isArray(uc) && uc.length >= 2) {
4521
+ x3 = uc[0];
4522
+ y3 = uc[1];
4523
+ }
4524
+ }
4525
+ } catch {
4526
+ }
4527
+ const hit = { x3, y3, z3, existingPointId };
4528
+ handleToolStep(ctx, tool, hit);
4529
+ };
4530
+ const svgEl = div.querySelector("svg");
4531
+ const targetEl = svgEl ?? div;
4532
+ const handlePointerDownEv = (e) => handlePointerDown(e);
4533
+ targetEl.addEventListener("pointerdown", handlePointerDownEv);
4534
+ pointerHandlerRef.current = { el: targetEl, fn: handlePointerDownEv };
4535
+ if (initialState?.elements?.length) {
4536
+ const map = objMapRef.current;
4537
+ for (const el of initialState.elements) {
4538
+ const parents = el.parents.map(
4539
+ (p2) => typeof p2 === "string" && p2.startsWith("@id:") ? map.get(p2.slice(4)) : p2
4540
+ );
4541
+ const obj = view.create(el.type, parents, {
4542
+ ...el.attributes,
4543
+ id: el.id,
4544
+ name: el.label
4545
+ });
4546
+ map.set(el.id, obj);
4547
+ logRef.current.push(el);
4548
+ }
3969
4549
  }
3970
- }
4550
+ })();
3971
4551
  return () => {
4552
+ cancelled = true;
3972
4553
  if (pointerHandlerRef.current) {
3973
4554
  pointerHandlerRef.current.el.removeEventListener(
3974
4555
  "pointerdown",
@@ -3977,7 +4558,7 @@ var MiniBoard3D = react.forwardRef(function MiniBoard3D2({ isDark, initialState
3977
4558
  pointerHandlerRef.current = null;
3978
4559
  }
3979
4560
  try {
3980
- JXG__default.default.JSXGraph.freeBoard(board);
4561
+ if (board && JXG) JXG.JSXGraph.freeBoard(board);
3981
4562
  } catch {
3982
4563
  }
3983
4564
  boardRef.current = null;
@@ -3994,7 +4575,25 @@ var MiniBoard3D = react.forwardRef(function MiniBoard3D2({ isDark, initialState
3994
4575
  toolRef.current = t;
3995
4576
  notify();
3996
4577
  },
3997
- getCreationLog: () => [...logRef.current],
4578
+ // Sync toạ độ live của free point3d về log trước khi trả ra. JSXGraph
4579
+ // cho phép drag point3d (parents=[x,y,z] không có ref), việc drag chỉ
4580
+ // cập nhật obj.X()/Y()/Z() chứ không đụng log → re-edit + Chèn sẽ
4581
+ // serialize toạ độ cũ → SVG không đổi → fileId trùng → user thấy
4582
+ // "k thay đổi". Line/plane/polygon/sphere tham chiếu point qua @id nên
4583
+ // auto-update theo.
4584
+ getCreationLog: () => logRef.current.map((e) => {
4585
+ if (e.type !== "point3d") return { ...e };
4586
+ const parents = e.parents;
4587
+ if (!Array.isArray(parents) || parents.length !== 3) return { ...e };
4588
+ if (typeof parents[0] !== "number" || typeof parents[1] !== "number" || typeof parents[2] !== "number") return { ...e };
4589
+ const obj = objMapRef.current.get(e.id);
4590
+ if (!obj || typeof obj.X !== "function" || typeof obj.Y !== "function" || typeof obj.Z !== "function") return { ...e };
4591
+ const x = obj.X();
4592
+ const y = obj.Y();
4593
+ const z = obj.Z();
4594
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return { ...e };
4595
+ return { ...e, parents: [x, y, z] };
4596
+ }),
3998
4597
  pushLog: (e) => {
3999
4598
  logRef.current.push(e);
4000
4599
  notify();
@@ -4070,6 +4669,138 @@ var MiniBoard3D = react.forwardRef(function MiniBoard3D2({ isDark, initialState
4070
4669
  }
4071
4670
  );
4072
4671
  });
4672
+ var EditorPanel = react.forwardRef(function EditorPanel2({ isDark, initial, onInsert, onClose, isMobile = false, withLeftPanel = false, onBoardReady, onOpenDrawer }, ref) {
4673
+ const boardRef = react.useRef(null);
4674
+ const [ready, setReady] = react.useState(false);
4675
+ const onBoardReadyRef = react.useRef(onBoardReady);
4676
+ onBoardReadyRef.current = onBoardReady;
4677
+ const setBoard = react.useCallback((h) => {
4678
+ boardRef.current = h;
4679
+ setReady(!!h);
4680
+ onBoardReadyRef.current?.(h);
4681
+ }, []);
4682
+ const performInsert = react.useCallback(() => {
4683
+ const board = boardRef.current;
4684
+ if (!board) return false;
4685
+ const log = board.getCreationLog();
4686
+ if (log.length === 0) return false;
4687
+ const view = board.getViewState();
4688
+ const state = {
4689
+ version: 1,
4690
+ bbox: board.getBbox(),
4691
+ view,
4692
+ showAxes: board.getShowAxes(),
4693
+ showMesh: board.getShowMesh(),
4694
+ elements: log
4695
+ };
4696
+ const snap = board.snapshotSVG();
4697
+ onInsert(JSON.stringify(state), snap.svgString, snap.width, snap.height);
4698
+ return true;
4699
+ }, [onInsert]);
4700
+ react.useImperativeHandle(
4701
+ ref,
4702
+ () => ({
4703
+ tryInsert: performInsert,
4704
+ hasContent: () => (boardRef.current?.getCreationLog().length ?? 0) > 0
4705
+ }),
4706
+ [performInsert]
4707
+ );
4708
+ const handleInsert = react.useCallback(() => {
4709
+ performInsert();
4710
+ }, [performInsert]);
4711
+ const wrapperStyle = isMobile ? { position: "fixed", inset: 0, zIndex: 40 } : {
4712
+ position: "absolute",
4713
+ top: "50%",
4714
+ left: withLeftPanel ? "calc(50% + 120px)" : "50%",
4715
+ transform: "translate(-50%, -50%)",
4716
+ zIndex: 40
4717
+ };
4718
+ return /* @__PURE__ */ jsxRuntime.jsxs(
4719
+ "div",
4720
+ {
4721
+ role: "dialog",
4722
+ "aria-label": "D\u1EF1ng h\xECnh h\u1ECDc 3D",
4723
+ "data-testid": "geom3d-editor-panel",
4724
+ "data-stamp-area": "true",
4725
+ "data-mobile-editor": isMobile ? "true" : void 0,
4726
+ style: wrapperStyle,
4727
+ className: [
4728
+ isDark ? "theme--dark " : "",
4729
+ "flex flex-col overflow-hidden bg-white",
4730
+ isMobile ? "h-full w-full" : "h-[600px] max-h-[85vh] w-[760px] max-w-[calc(100vw-280px)] rounded-lg border border-slate-300 shadow-2xl ring-1 ring-black/5"
4731
+ ].join(" "),
4732
+ children: [
4733
+ /* @__PURE__ */ jsxRuntime.jsxs("header", { className: "flex items-center gap-2 border-b border-slate-200 bg-gradient-to-r from-blue-600 to-cyan-600 px-3 py-2 text-white", children: [
4734
+ isMobile && /* @__PURE__ */ jsxRuntime.jsx(
4735
+ "button",
4736
+ {
4737
+ type: "button",
4738
+ onClick: onOpenDrawer,
4739
+ "aria-label": "M\u1EDF ng\u0103n c\xF4ng c\u1EE5",
4740
+ className: "-ml-1 inline-flex h-10 w-10 items-center justify-center rounded transition hover:bg-white/15",
4741
+ children: /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
4742
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "4", y1: "6", x2: "20", y2: "6" }),
4743
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "4", y1: "12", x2: "20", y2: "12" }),
4744
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "4", y1: "18", x2: "20", y2: "18" })
4745
+ ] })
4746
+ }
4747
+ ),
4748
+ /* @__PURE__ */ jsxRuntime.jsxs("h3", { className: "flex flex-1 items-center gap-2 text-sm font-semibold", children: [
4749
+ /* @__PURE__ */ jsxRuntime.jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M4 7 L14 4 L20 7 L14 10 Z M4 7 L4 17 L14 20 L14 10 M14 20 L20 17 L20 7" }) }),
4750
+ "H\xECnh h\u1ECDc kh\xF4ng gian (3D)"
4751
+ ] }),
4752
+ isMobile && /* @__PURE__ */ jsxRuntime.jsx(
4753
+ "button",
4754
+ {
4755
+ type: "button",
4756
+ onClick: handleInsert,
4757
+ disabled: !ready,
4758
+ "data-testid": "geom3d-insert-btn-mobile",
4759
+ className: "rounded bg-white/15 px-3 py-1.5 text-xs font-semibold transition hover:bg-white/25 disabled:opacity-50",
4760
+ children: "Ch\xE8n"
4761
+ }
4762
+ ),
4763
+ /* @__PURE__ */ jsxRuntime.jsx(
4764
+ "button",
4765
+ {
4766
+ onClick: onClose,
4767
+ "aria-label": "\u0110\xF3ng",
4768
+ className: "inline-flex h-9 w-9 items-center justify-center rounded transition hover:bg-white/15",
4769
+ children: /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
4770
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
4771
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
4772
+ ] })
4773
+ }
4774
+ )
4775
+ ] }),
4776
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "min-h-0 flex-1", children: /* @__PURE__ */ jsxRuntime.jsx(MiniBoard3D, { ref: setBoard, isDark, initialState: initial }) }),
4777
+ !isMobile && /* @__PURE__ */ jsxRuntime.jsxs("footer", { className: "flex items-center justify-between border-t border-slate-200 bg-slate-50 px-3 py-2", children: [
4778
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-xs text-slate-500", children: "Ch\u1ECDn c\xF4ng c\u1EE5 b\xEAn tr\xE1i, click tr\xEAn b\u1EA3ng \u0111\u1EC3 d\u1EF1ng h\xECnh." }),
4779
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex gap-2", children: [
4780
+ /* @__PURE__ */ jsxRuntime.jsx(
4781
+ "button",
4782
+ {
4783
+ onClick: onClose,
4784
+ className: "rounded border border-slate-300 bg-white px-3 py-1 text-xs font-medium text-slate-700 transition hover:bg-slate-100",
4785
+ children: "Hu\u1EF7"
4786
+ }
4787
+ ),
4788
+ /* @__PURE__ */ jsxRuntime.jsx(
4789
+ "button",
4790
+ {
4791
+ onClick: handleInsert,
4792
+ disabled: !ready,
4793
+ "data-testid": "geom3d-insert-btn",
4794
+ className: "rounded bg-blue-600 px-3 py-1 text-xs font-medium text-white transition hover:bg-blue-700 disabled:opacity-50",
4795
+ children: "Ch\xE8n"
4796
+ }
4797
+ )
4798
+ ] })
4799
+ ] })
4800
+ ]
4801
+ }
4802
+ );
4803
+ });
4073
4804
 
4074
4805
  // src/stamps/geometry-3d/editor/tools.ts
4075
4806
  var GROUP_LABELS_3D = {
@@ -4079,6 +4810,18 @@ var GROUP_LABELS_3D = {
4079
4810
  curved: "Kh\u1ED1i cong",
4080
4811
  meta: "Kh\xE1c"
4081
4812
  };
4813
+ var GROUP_ORDER_3D = [
4814
+ "view",
4815
+ "primitive",
4816
+ "solid",
4817
+ "curved",
4818
+ "meta"
4819
+ ];
4820
+ var A_CODE_3D = "A".charCodeAt(0);
4821
+ function letterForGroup3D(g) {
4822
+ const idx = GROUP_ORDER_3D.indexOf(g);
4823
+ return idx >= 0 ? String.fromCharCode(A_CODE_3D + idx) : "";
4824
+ }
4082
4825
  var TOOLS_3D = [
4083
4826
  { key: "move", label: "Di chuy\u1EC3n", group: "view", stepsRequired: 0 },
4084
4827
  { key: "point", label: "\u0110i\u1EC3m", group: "primitive", stepsRequired: 1, hint: "Nh\u1EADp (x, y, z)" },
@@ -4132,8 +4875,8 @@ var TOOLS_3D = [
4132
4875
  },
4133
4876
  { key: "label", label: "Nh\xE3n", group: "meta", stepsRequired: 1, hint: "G\u1EAFn v\xE0o \u0111i\u1EC3m" }
4134
4877
  ];
4135
- function ToolButton({ toolKey, label, hint, active, onClick, icon }) {
4136
- return /* @__PURE__ */ jsxRuntime.jsx(
4878
+ function ToolButton({ toolKey, label, hint, active, onClick, icon, badge }) {
4879
+ return /* @__PURE__ */ jsxRuntime.jsxs(
4137
4880
  "button",
4138
4881
  {
4139
4882
  type: "button",
@@ -4144,10 +4887,13 @@ function ToolButton({ toolKey, label, hint, active, onClick, icon }) {
4144
4887
  "data-active": active || void 0,
4145
4888
  "data-tool": toolKey,
4146
4889
  className: [
4147
- "flex h-8 items-center justify-center rounded-md transition",
4890
+ "relative flex h-8 items-center justify-center rounded-md transition",
4148
4891
  active ? "bg-blue-600 text-white shadow-sm" : "text-slate-700 hover:bg-slate-100 hover:text-slate-900"
4149
4892
  ].join(" "),
4150
- children: icon
4893
+ children: [
4894
+ icon,
4895
+ badge
4896
+ ]
4151
4897
  }
4152
4898
  );
4153
4899
  }
@@ -4199,7 +4945,10 @@ function Shell3({ title, icon, onClose, children, isDark }) {
4199
4945
  "aria-label": title,
4200
4946
  "data-testid": "geom3d-left-panel",
4201
4947
  "data-stamp-area": "true",
4202
- className: `${isDark ? "theme--dark " : ""}absolute left-0 top-0 z-30 flex h-full w-60 flex-col border-r border-slate-200 bg-white shadow-md animate-in slide-in-from-left duration-200`,
4948
+ className: [
4949
+ isDark ? "theme--dark " : "",
4950
+ "absolute left-0 top-0 z-30 flex h-full w-60 flex-col border-r border-slate-200 bg-white shadow-md animate-in slide-in-from-left duration-200"
4951
+ ].join(""),
4203
4952
  children: [
4204
4953
  /* @__PURE__ */ jsxRuntime.jsxs("header", { className: "flex items-center justify-between border-b border-slate-200 bg-gradient-to-r from-slate-50 to-white px-3 py-2", children: [
4205
4954
  /* @__PURE__ */ jsxRuntime.jsxs("h3", { className: "flex items-center gap-2 text-sm font-semibold text-slate-800", children: [
@@ -4212,10 +4961,7 @@ function Shell3({ title, icon, onClose, children, isDark }) {
4212
4961
  onClick: onClose,
4213
4962
  "aria-label": "\u0110\xF3ng",
4214
4963
  className: "rounded p-1 text-slate-500 transition hover:bg-slate-100 hover:text-slate-800",
4215
- children: /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
4216
- /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
4217
- /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
4218
- ] })
4964
+ children: /* @__PURE__ */ jsxRuntime.jsx(CloseIcon2, {})
4219
4965
  }
4220
4966
  )
4221
4967
  ] }),
@@ -4231,11 +4977,39 @@ function Section3({ label, children }) {
4231
4977
  ] });
4232
4978
  }
4233
4979
  var Geom3DIconHeader = /* @__PURE__ */ jsxRuntime.jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M4 7 L14 4 L20 7 L14 10 Z M4 7 L4 17 L14 20 L14 10 M14 20 L20 17 L20 7" }) });
4234
- function LeftPanel3({ handle, onResetView, onClose, isDark }) {
4235
- const [tool, setTool] = react.useState("move");
4236
- const [showAxes, setShowAxes] = react.useState(true);
4237
- const [showMesh, setShowMesh] = react.useState(false);
4238
- const [canUndo, setCanUndo] = react.useState(false);
4980
+ function CloseIcon2() {
4981
+ return /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
4982
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
4983
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
4984
+ ] });
4985
+ }
4986
+ function UndoIcon2() {
4987
+ return /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
4988
+ /* @__PURE__ */ jsxRuntime.jsx("polyline", { points: "3 7 3 13 9 13" }),
4989
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3.51 13a9 9 0 1 0 2.13-9.36L3 7" })
4990
+ ] });
4991
+ }
4992
+ function ResetViewIcon() {
4993
+ return /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
4994
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" }),
4995
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3 3v5h5" })
4996
+ ] });
4997
+ }
4998
+ function AxisIcon3D() {
4999
+ return /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", children: [
5000
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "12", y1: "20", x2: "12", y2: "4" }),
5001
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "12", y1: "12", x2: "22", y2: "6" }),
5002
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "12", y1: "12", x2: "2", y2: "18" })
5003
+ ] });
5004
+ }
5005
+ function MeshIcon() {
5006
+ return /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round", strokeLinejoin: "round", children: [
5007
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M4 8 L12 4 L20 8 L12 12 Z" }),
5008
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M4 8 L4 16 L12 20 L12 12" }),
5009
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M12 20 L20 16 L20 8" })
5010
+ ] });
5011
+ }
5012
+ function useToolHoverTooltip2() {
4239
5013
  const [hover, setHover] = react.useState(null);
4240
5014
  const [portalReady, setPortalReady] = react.useState(false);
4241
5015
  const hoverTimerRef = react.useRef(null);
@@ -4245,17 +5019,6 @@ function LeftPanel3({ handle, onResetView, onClose, isDark }) {
4245
5019
  if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
4246
5020
  };
4247
5021
  }, []);
4248
- react.useEffect(() => {
4249
- if (!handle) return;
4250
- const sync = () => {
4251
- setTool(handle.getTool());
4252
- setShowAxes(handle.getShowAxes());
4253
- setShowMesh(handle.getShowMesh());
4254
- setCanUndo(handle.canUndo());
4255
- };
4256
- sync();
4257
- return handle.subscribe(sync);
4258
- }, [handle]);
4259
5022
  const showHover = react.useCallback((el, t) => {
4260
5023
  if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
4261
5024
  hoverTimerRef.current = setTimeout(() => {
@@ -4270,14 +5033,45 @@ function LeftPanel3({ handle, onResetView, onClose, isDark }) {
4270
5033
  }
4271
5034
  setHover(null);
4272
5035
  }, []);
4273
- const grouped = TOOLS_3D.reduce(
4274
- (acc, t) => {
4275
- var _a;
4276
- (acc[_a = t.group] ?? (acc[_a] = [])).push(t);
4277
- return acc;
4278
- },
4279
- {}
5036
+ return { hover, portalReady, showHover, hideHover };
5037
+ }
5038
+ function useHandleState(handle) {
5039
+ const [tool, setTool] = react.useState("move");
5040
+ const [showAxes, setShowAxes] = react.useState(true);
5041
+ const [showMesh, setShowMesh] = react.useState(false);
5042
+ const [canUndo, setCanUndo] = react.useState(false);
5043
+ react.useEffect(() => {
5044
+ if (!handle) return;
5045
+ const sync = () => {
5046
+ setTool(handle.getTool());
5047
+ setShowAxes(handle.getShowAxes());
5048
+ setShowMesh(handle.getShowMesh());
5049
+ setCanUndo(handle.canUndo());
5050
+ };
5051
+ sync();
5052
+ return handle.subscribe(sync);
5053
+ }, [handle]);
5054
+ return { tool, showAxes, showMesh, canUndo };
5055
+ }
5056
+ function DesktopPanel(props) {
5057
+ const { handle, onResetView, onClose, isDark, chordGroup } = props;
5058
+ const { tool, showAxes, showMesh, canUndo } = useHandleState(handle);
5059
+ const { hover, portalReady, showHover, hideHover } = useToolHoverTooltip2();
5060
+ const grouped = react.useMemo(() => {
5061
+ return TOOLS_3D.reduce(
5062
+ (acc, t) => {
5063
+ var _a;
5064
+ (acc[_a = t.group] ?? (acc[_a] = [])).push(t);
5065
+ return acc;
5066
+ },
5067
+ {}
5068
+ );
5069
+ }, []);
5070
+ const orderedGroups = react.useMemo(
5071
+ () => GROUP_ORDER_3D.filter((g) => grouped[g]),
5072
+ [grouped]
4280
5073
  );
5074
+ const activeGroupTools = chordGroup ? grouped[chordGroup] ?? null : null;
4281
5075
  return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
4282
5076
  /* @__PURE__ */ jsxRuntime.jsxs(Shell3, { title: "H\xECnh h\u1ECDc 3D", icon: Geom3DIconHeader, onClose, isDark, children: [
4283
5077
  /* @__PURE__ */ jsxRuntime.jsx(Section3, { label: "B\u1ED1 c\u1EE5c", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2 flex-wrap text-[11px] text-slate-700", children: [
@@ -4313,10 +5107,7 @@ function LeftPanel3({ handle, onResetView, onClose, isDark }) {
4313
5107
  title: "Reset g\xF3c nh\xECn",
4314
5108
  "aria-label": "Reset view",
4315
5109
  className: "ml-auto inline-flex items-center justify-center rounded p-1 text-slate-600 transition hover:bg-slate-100 hover:text-slate-900",
4316
- children: /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
4317
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" }),
4318
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3 3v5h5" })
4319
- ] })
5110
+ children: /* @__PURE__ */ jsxRuntime.jsx(ResetViewIcon, {})
4320
5111
  }
4321
5112
  ),
4322
5113
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -4328,34 +5119,95 @@ function LeftPanel3({ handle, onResetView, onClose, isDark }) {
4328
5119
  title: "Ho\xE0n t\xE1c (Ctrl/Cmd+Z)",
4329
5120
  "aria-label": "Ho\xE0n t\xE1c",
4330
5121
  className: "inline-flex items-center justify-center rounded p-1 text-slate-600 transition hover:bg-slate-100 hover:text-slate-900 disabled:cursor-not-allowed disabled:text-slate-300 disabled:hover:bg-transparent",
4331
- children: /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
4332
- /* @__PURE__ */ jsxRuntime.jsx("polyline", { points: "3 7 3 13 9 13" }),
4333
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3.51 13a9 9 0 1 0 2.13-9.36L3 7" })
4334
- ] })
5122
+ children: /* @__PURE__ */ jsxRuntime.jsx(UndoIcon2, {})
4335
5123
  }
4336
5124
  )
4337
5125
  ] }) }),
4338
- Object.entries(grouped).map(([group, tools]) => /* @__PURE__ */ jsxRuntime.jsx(Section3, { label: GROUP_LABELS_3D[group], children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "grid grid-cols-4 gap-1", children: tools.map((t) => /* @__PURE__ */ jsxRuntime.jsx(
4339
- ToolButton,
5126
+ orderedGroups.map((group) => {
5127
+ const tools = grouped[group];
5128
+ const isChordActive = chordGroup === group;
5129
+ const dimmed = chordGroup !== null && !isChordActive;
5130
+ return /* @__PURE__ */ jsxRuntime.jsxs(
5131
+ "section",
5132
+ {
5133
+ "data-chord-group": group,
5134
+ "data-chord-active": isChordActive ? "true" : "false",
5135
+ className: [
5136
+ "rounded-md transition",
5137
+ isChordActive ? "bg-blue-50 ring-1 ring-blue-400 p-1" : "p-0",
5138
+ dimmed ? "opacity-55" : "opacity-100"
5139
+ ].join(" "),
5140
+ children: [
5141
+ /* @__PURE__ */ jsxRuntime.jsxs("h4", { className: "mb-1.5 flex items-center justify-between text-[10px] font-semibold uppercase tracking-wider text-slate-500", children: [
5142
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: GROUP_LABELS_3D[group] }),
5143
+ /* @__PURE__ */ jsxRuntime.jsx(
5144
+ "span",
5145
+ {
5146
+ "data-testid": `chord-letter-${group}`,
5147
+ className: [
5148
+ "font-mono text-[10px] leading-none transition",
5149
+ isChordActive ? "text-blue-700 font-bold" : "text-slate-400"
5150
+ ].join(" "),
5151
+ children: letterForGroup3D(group)
5152
+ }
5153
+ )
5154
+ ] }),
5155
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "grid grid-cols-4 gap-1", children: tools.map((t, i) => {
5156
+ const isActive = tool === t.key;
5157
+ return /* @__PURE__ */ jsxRuntime.jsx(
5158
+ ToolButton,
5159
+ {
5160
+ toolKey: t.key,
5161
+ label: t.label,
5162
+ hint: t.hint,
5163
+ active: isActive,
5164
+ onClick: () => handle?.setTool(t.key),
5165
+ icon: /* @__PURE__ */ jsxRuntime.jsx(
5166
+ "span",
5167
+ {
5168
+ onMouseEnter: (e) => showHover(e.currentTarget.closest("button"), t),
5169
+ onMouseLeave: hideHover,
5170
+ onFocus: (e) => showHover(e.currentTarget.closest("button"), t),
5171
+ onBlur: hideHover,
5172
+ children: ICONS_3D[t.key]
5173
+ }
5174
+ ),
5175
+ badge: /* @__PURE__ */ jsxRuntime.jsx(
5176
+ "span",
5177
+ {
5178
+ "data-testid": `chord-num-${t.key}`,
5179
+ className: [
5180
+ "pointer-events-none absolute bottom-0 right-0.5 font-mono text-[9px] leading-none transition",
5181
+ isActive ? "text-white/70" : isChordActive ? "text-blue-700 font-bold" : "text-slate-400"
5182
+ ].join(" "),
5183
+ children: i + 1
5184
+ }
5185
+ )
5186
+ },
5187
+ t.key
5188
+ );
5189
+ }) })
5190
+ ]
5191
+ },
5192
+ group
5193
+ );
5194
+ }),
5195
+ chordGroup && activeGroupTools && /* @__PURE__ */ jsxRuntime.jsxs(
5196
+ "div",
4340
5197
  {
4341
- toolKey: t.key,
4342
- label: t.label,
4343
- hint: t.hint,
4344
- active: tool === t.key,
4345
- onClick: () => handle?.setTool(t.key),
4346
- icon: /* @__PURE__ */ jsxRuntime.jsx(
4347
- "span",
4348
- {
4349
- onMouseEnter: (e) => showHover(e.currentTarget.closest("button"), t),
4350
- onMouseLeave: hideHover,
4351
- onFocus: (e) => showHover(e.currentTarget.closest("button"), t),
4352
- onBlur: hideHover,
4353
- children: ICONS_3D[t.key]
4354
- }
4355
- )
4356
- },
4357
- t.key
4358
- )) }) }, group))
5198
+ "data-testid": "chord-hint",
5199
+ className: "mt-1 rounded border border-blue-200 bg-blue-50/60 px-2 py-1 text-[11px] leading-snug text-slate-600",
5200
+ children: [
5201
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "font-mono font-semibold text-blue-700", children: letterForGroup3D(chordGroup) }),
5202
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "mx-1 text-slate-400", children: "\u2192" }),
5203
+ activeGroupTools.map((t, i) => /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "mr-2 inline-block", children: [
5204
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "font-mono font-semibold text-blue-700", children: i + 1 }),
5205
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "ml-1", children: t.label })
5206
+ ] }, t.key)),
5207
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-slate-400", children: "Esc hu\u1EF7" })
5208
+ ]
5209
+ }
5210
+ )
4359
5211
  ] }),
4360
5212
  portalReady && hover && typeof document !== "undefined" ? reactDom.createPortal(
4361
5213
  /* @__PURE__ */ jsxRuntime.jsxs(
@@ -4379,107 +5231,71 @@ function LeftPanel3({ handle, onResetView, onClose, isDark }) {
4379
5231
  ) : null
4380
5232
  ] });
4381
5233
  }
4382
- var EditorPanel = react.forwardRef(function EditorPanel2({ isDark, initial, onInsert, onClose }, ref) {
4383
- const boardRef = react.useRef(null);
4384
- const [boardHandle, setBoardHandle] = react.useState(null);
4385
- const setBoard = react.useCallback((h) => {
4386
- boardRef.current = h;
4387
- setBoardHandle((prev) => prev === h ? prev : h);
4388
- }, []);
4389
- react.useImperativeHandle(
4390
- ref,
4391
- () => ({
4392
- tryInsert: () => {
4393
- const board = boardRef.current;
4394
- if (!board) return false;
4395
- const log = board.getCreationLog();
4396
- if (log.length === 0) return false;
4397
- const view = board.getViewState();
4398
- const state = {
4399
- version: 1,
4400
- bbox: board.getBbox(),
4401
- view,
4402
- showAxes: board.getShowAxes(),
4403
- showMesh: board.getShowMesh(),
4404
- elements: log
4405
- };
4406
- const snap = board.snapshotSVG();
4407
- onInsert(JSON.stringify(state), snap.svgString, snap.width, snap.height);
4408
- return true;
4409
- },
4410
- hasContent: () => (boardRef.current?.getCreationLog().length ?? 0) > 0
4411
- }),
4412
- [onInsert]
4413
- );
4414
- const handleResetView = react.useCallback(() => {
4415
- boardRef.current?.resetView();
5234
+ function MobilePanel(props) {
5235
+ const { handle, onResetView, isDark, drawerOpen, onDrawerClose } = props;
5236
+ const { tool, showAxes, showMesh, canUndo } = useHandleState(handle);
5237
+ const groups = react.useMemo(() => {
5238
+ const acc = /* @__PURE__ */ new Map();
5239
+ for (const t of TOOLS_3D) {
5240
+ if (!acc.has(t.group)) acc.set(t.group, []);
5241
+ acc.get(t.group).push(t);
5242
+ }
5243
+ return Array.from(acc.entries()).map(([group, tools]) => ({
5244
+ group,
5245
+ groupLabel: GROUP_LABELS_3D[group],
5246
+ tools: tools.map((t) => ({ key: t.key, label: t.label, icon: ICONS_3D[t.key] }))
5247
+ }));
4416
5248
  }, []);
4417
- return /* @__PURE__ */ jsxRuntime.jsxs(
4418
- "div",
5249
+ return /* @__PURE__ */ jsxRuntime.jsx(
5250
+ MobileToolDrawer,
4419
5251
  {
4420
- "data-testid": "geom3d-editor-panel",
4421
- "data-stamp-area": "true",
4422
- style: {
4423
- position: "absolute",
4424
- left: "50%",
4425
- top: "50%",
4426
- transform: "translate(-50%, -50%)",
4427
- width: 900,
4428
- height: 700,
4429
- background: "#fff",
4430
- boxShadow: "0 6px 32px rgba(0,0,0,0.2)",
4431
- borderRadius: 8,
4432
- zIndex: 10,
4433
- display: "flex",
4434
- flexDirection: "column",
4435
- overflow: "hidden"
4436
- },
4437
- children: [
4438
- /* @__PURE__ */ jsxRuntime.jsxs(
4439
- "div",
4440
- {
4441
- style: {
4442
- display: "flex",
4443
- padding: "8px 12px",
4444
- borderBottom: "1px solid #eee",
4445
- alignItems: "center"
4446
- },
4447
- children: [
4448
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: { fontWeight: 600 }, children: "H\xECnh h\u1ECDc kh\xF4ng gian (3D)" }),
4449
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: { flex: 1 } }),
4450
- /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", onClick: onClose, children: "\u0110\xF3ng" })
4451
- ]
4452
- }
4453
- ),
4454
- /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { position: "relative", flex: 1, minHeight: 0 }, children: [
4455
- /* @__PURE__ */ jsxRuntime.jsx(
4456
- LeftPanel3,
4457
- {
4458
- handle: boardHandle,
4459
- onResetView: handleResetView,
4460
- onClose,
4461
- isDark
4462
- }
4463
- ),
4464
- /* @__PURE__ */ jsxRuntime.jsx(
4465
- "div",
4466
- {
4467
- style: {
4468
- position: "absolute",
4469
- left: 120,
4470
- top: 0,
4471
- right: 0,
4472
- bottom: 0,
4473
- overflow: "hidden"
4474
- },
4475
- children: /* @__PURE__ */ jsxRuntime.jsx(MiniBoard3D, { ref: setBoard, isDark, initialState: initial })
4476
- }
4477
- )
4478
- ] })
4479
- ]
5252
+ title: "H\xECnh h\u1ECDc 3D",
5253
+ headerIcon: Geom3DIconHeader,
5254
+ testId: "geom3d-left-panel",
5255
+ isDark,
5256
+ drawerOpen: !!drawerOpen,
5257
+ onDrawerClose: () => onDrawerClose?.(),
5258
+ chips: [
5259
+ {
5260
+ label: "Tr\u1EE5c",
5261
+ icon: /* @__PURE__ */ jsxRuntime.jsx(AxisIcon3D, {}),
5262
+ pressed: showAxes,
5263
+ onToggle: (b) => handle?.setShowAxes(b),
5264
+ testId: "toggle-axes"
5265
+ },
5266
+ {
5267
+ label: "L\u01B0\u1EDBi",
5268
+ icon: /* @__PURE__ */ jsxRuntime.jsx(MeshIcon, {}),
5269
+ pressed: showMesh,
5270
+ onToggle: (b) => handle?.setShowMesh(b),
5271
+ testId: "toggle-mesh"
5272
+ }
5273
+ ],
5274
+ actions: [
5275
+ {
5276
+ label: "Reset view",
5277
+ title: "Reset g\xF3c nh\xECn",
5278
+ icon: /* @__PURE__ */ jsxRuntime.jsx(ResetViewIcon, {}),
5279
+ onClick: onResetView
5280
+ },
5281
+ {
5282
+ label: "Ho\xE0n t\xE1c",
5283
+ title: "Ho\xE0n t\xE1c (Ctrl/Cmd+Z)",
5284
+ icon: /* @__PURE__ */ jsxRuntime.jsx(UndoIcon2, {}),
5285
+ onClick: () => handle?.undo(),
5286
+ disabled: !canUndo
5287
+ }
5288
+ ],
5289
+ groups,
5290
+ activeTool: tool,
5291
+ onToolSelect: (k) => handle?.setTool(k)
4480
5292
  }
4481
5293
  );
4482
- });
5294
+ }
5295
+ function LeftPanel3(props) {
5296
+ if (props.isMobile) return /* @__PURE__ */ jsxRuntime.jsx(MobilePanel, { ...props });
5297
+ return /* @__PURE__ */ jsxRuntime.jsx(DesktopPanel, { ...props });
5298
+ }
4483
5299
 
4484
5300
  // src/stamps/geometry-3d/serialize.ts
4485
5301
  function isGeometry3DCustomData(data) {
@@ -4501,16 +5317,19 @@ function parseSerializedBoard3D(json) {
4501
5317
  }
4502
5318
  return parsed;
4503
5319
  }
5320
+
5321
+ // src/stamps/geometry-3d/render.ts
4504
5322
  var OUTPUT_WIDTH = 1024;
4505
5323
  var OUTPUT_HEIGHT = 768;
4506
5324
  async function renderGeometry3DSvgFromState(jsonState) {
4507
5325
  const state = parseSerializedBoard3D(jsonState);
5326
+ const JXG = (await import('jsxgraph')).default;
4508
5327
  const div = document.createElement("div");
4509
5328
  div.style.cssText = `position:absolute;left:-9999px;top:-9999px;width:${OUTPUT_WIDTH}px;height:${OUTPUT_HEIGHT}px;`;
4510
5329
  document.body.appendChild(div);
4511
5330
  try {
4512
- JXG__default.default.Options.text.display = "internal";
4513
- const board = JXG__default.default.JSXGraph.initBoard(div, {
5331
+ JXG.Options.text.display = "internal";
5332
+ const board = JXG.JSXGraph.initBoard(div, {
4514
5333
  boundingbox: state.bbox,
4515
5334
  axis: false,
4516
5335
  showCopyright: false,
@@ -4558,69 +5377,1531 @@ async function renderGeometry3DSvgFromState(jsonState) {
4558
5377
  clone.setAttribute("height", String(OUTPUT_HEIGHT));
4559
5378
  const svgString = new XMLSerializer().serializeToString(clone);
4560
5379
  try {
4561
- JXG__default.default.JSXGraph.freeBoard(board);
5380
+ JXG.JSXGraph.freeBoard(board);
4562
5381
  } catch {
4563
5382
  }
4564
- return { svgString, width: OUTPUT_WIDTH, height: OUTPUT_HEIGHT };
4565
- } finally {
4566
- document.body.removeChild(div);
4567
- }
4568
- }
4569
- function parseInitial(editingElement) {
4570
- if (!editingElement) return null;
4571
- if (!isGeometry3DCustomData(editingElement.customData)) return null;
4572
- try {
4573
- return parseSerializedBoard3D(editingElement.customData.jsonState);
4574
- } catch {
4575
- return null;
4576
- }
5383
+ return { svgString, width: OUTPUT_WIDTH, height: OUTPUT_HEIGHT };
5384
+ } finally {
5385
+ document.body.removeChild(div);
5386
+ }
5387
+ }
5388
+ function parseInitial(editingElement) {
5389
+ if (!editingElement) return null;
5390
+ if (!isGeometry3DCustomData(editingElement.customData)) return null;
5391
+ try {
5392
+ return parseSerializedBoard3D(editingElement.customData.jsonState);
5393
+ } catch {
5394
+ return null;
5395
+ }
5396
+ }
5397
+ var Geometry3DStampHost = react.forwardRef(
5398
+ function Geometry3DStampHost2({ api, editingElement, onClose, isDark }, ref) {
5399
+ const editorRef = react.useRef(null);
5400
+ const { isMobile } = useIsMobile();
5401
+ const [drawerOpen, setDrawerOpen] = react.useState(false);
5402
+ const [boardHandle, setBoardHandle] = react.useState(null);
5403
+ const initial = react.useMemo(
5404
+ () => parseInitial(editingElement),
5405
+ [editingElement]
5406
+ );
5407
+ const handleBoardReady = react.useCallback((h) => {
5408
+ setBoardHandle((prev) => prev === h ? prev : h);
5409
+ }, []);
5410
+ const { chordGroup } = useChordShortcut({
5411
+ groupOrder: GROUP_ORDER_3D,
5412
+ tools: TOOLS_3D,
5413
+ onSelect: (key) => boardHandle?.setTool(key),
5414
+ enabled: !isMobile
5415
+ });
5416
+ const handleResetView = react.useCallback(() => {
5417
+ boardHandle?.resetView();
5418
+ }, [boardHandle]);
5419
+ const handleInsert = react.useCallback(
5420
+ async (jsonState, svgString, width, height) => {
5421
+ if (!api) return;
5422
+ await insertStampImage(api, {
5423
+ svgString,
5424
+ makeCustomData: () => ({
5425
+ kind: "geometry3d",
5426
+ version: 1,
5427
+ jsonState,
5428
+ svgWidth: width,
5429
+ svgHeight: height
5430
+ }),
5431
+ editingElementId: editingElement?.id ?? null
5432
+ });
5433
+ onClose();
5434
+ },
5435
+ [api, editingElement, onClose]
5436
+ );
5437
+ react.useImperativeHandle(
5438
+ ref,
5439
+ () => ({
5440
+ tryInsert: () => editorRef.current?.tryInsert() ?? false,
5441
+ hasContent: () => editorRef.current?.hasContent() ?? false
5442
+ }),
5443
+ []
5444
+ );
5445
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
5446
+ /* @__PURE__ */ jsxRuntime.jsx(
5447
+ LeftPanel3,
5448
+ {
5449
+ handle: boardHandle,
5450
+ onResetView: handleResetView,
5451
+ onClose,
5452
+ isDark,
5453
+ isMobile,
5454
+ drawerOpen,
5455
+ onDrawerClose: () => setDrawerOpen(false),
5456
+ chordGroup
5457
+ }
5458
+ ),
5459
+ /* @__PURE__ */ jsxRuntime.jsx(
5460
+ EditorPanel,
5461
+ {
5462
+ ref: editorRef,
5463
+ isDark,
5464
+ initial,
5465
+ onInsert: handleInsert,
5466
+ onClose,
5467
+ isMobile,
5468
+ withLeftPanel: !isMobile,
5469
+ onBoardReady: handleBoardReady,
5470
+ onOpenDrawer: () => setDrawerOpen(true)
5471
+ }
5472
+ )
5473
+ ] });
5474
+ }
5475
+ );
5476
+ var Geometry3DIcon = /* @__PURE__ */ jsxRuntime.jsxs(
5477
+ "svg",
5478
+ {
5479
+ width: "20",
5480
+ height: "20",
5481
+ viewBox: "0 0 24 24",
5482
+ fill: "none",
5483
+ stroke: "currentColor",
5484
+ strokeWidth: "1.6",
5485
+ strokeLinecap: "round",
5486
+ strokeLinejoin: "round",
5487
+ "aria-hidden": "true",
5488
+ children: [
5489
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M12 3 L20 8 L20 16 L12 21 L4 16 L4 8 Z" }),
5490
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M12 3 L12 21 M4 8 L12 12 L20 8 M4 16 L12 12 L20 16" })
5491
+ ]
5492
+ }
5493
+ );
5494
+ var geometry3dStamp = {
5495
+ kind: "geometry3d",
5496
+ shortcutKey: "d",
5497
+ toolbarLabel: "D",
5498
+ toolbarTitle: "H\xECnh 3D (D)",
5499
+ toolbarIcon: Geometry3DIcon,
5500
+ toolbarTestId: "stamp-toolbar-geometry3d",
5501
+ matchesCustomData: isGeometry3DCustomData,
5502
+ async renderSvgFromCustomData(data) {
5503
+ if (!isGeometry3DCustomData(data)) {
5504
+ throw new Error("geometry3dStamp.renderSvgFromCustomData: customData kh\xF4ng ph\u1EA3i geometry3d");
5505
+ }
5506
+ const { svgString } = await renderGeometry3DSvgFromState(data.jsonState);
5507
+ return svgString;
5508
+ },
5509
+ restoreFileFromCustomData: async (element) => {
5510
+ const data = element.customData;
5511
+ const fileId = element.fileId;
5512
+ if (!data || !fileId) return null;
5513
+ if (!isGeometry3DCustomData(data)) return null;
5514
+ try {
5515
+ const { svgString } = await renderGeometry3DSvgFromState(data.jsonState);
5516
+ const dataURL = `data:image/svg+xml;base64,${typeof btoa !== "undefined" ? btoa(unescape(encodeURIComponent(svgString))) : Buffer.from(svgString).toString("base64")}`;
5517
+ return { fileId, dataURL, mimeType: "image/svg+xml" };
5518
+ } catch {
5519
+ return null;
5520
+ }
5521
+ },
5522
+ Host: Geometry3DStampHost
5523
+ };
5524
+
5525
+ // src/stamps/graph-2d/editor/tools.ts
5526
+ var GRAPH_TOOLS = [
5527
+ { id: "move", label: "Di chuy\u1EC3n", title: "Di chuy\u1EC3n / ch\u1ECDn" },
5528
+ { id: "point-on-curve", label: "\u0110i\u1EC3m tr\xEAn curve", title: "T\u1EA1o \u0111i\u1EC3m c\u1ED1 \u0111\u1ECBnh tr\xEAn \u0111\u1ED3 th\u1ECB" },
5529
+ { id: "intersect", label: "Giao \u0111i\u1EC3m", title: "\u0110\xE1nh d\u1EA5u giao \u0111i\u1EC3m 2 \u0111\u1ED3 th\u1ECB" },
5530
+ { id: "tangent", label: "Ti\u1EBFp tuy\u1EBFn", title: "V\u1EBD ti\u1EBFp tuy\u1EBFn t\u1EA1i \u0111i\u1EC3m tr\xEAn \u0111\u1ED3 th\u1ECB" }
5531
+ ];
5532
+ function FunctionRow(props) {
5533
+ const { id, name, expression, color, visible, error } = props;
5534
+ const [draft, setDraft] = react.useState(expression);
5535
+ react.useEffect(() => {
5536
+ setDraft(expression);
5537
+ }, [expression]);
5538
+ const commit = () => {
5539
+ if (draft !== expression) props.onExpressionCommit(draft);
5540
+ };
5541
+ const handleKeyDown = (e) => {
5542
+ if (e.key === "Enter") {
5543
+ e.preventDefault();
5544
+ commit();
5545
+ e.target.blur();
5546
+ } else if (e.key === "Escape") {
5547
+ setDraft(expression);
5548
+ e.target.blur();
5549
+ }
5550
+ };
5551
+ const handleBlur = (_) => commit();
5552
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: `graph-function-row${error ? " is-error" : ""}`, "data-testid": `graph-function-row-${id}`, children: [
5553
+ /* @__PURE__ */ jsxRuntime.jsx(
5554
+ "span",
5555
+ {
5556
+ className: "graph-function-color",
5557
+ style: { backgroundColor: color },
5558
+ "aria-hidden": "true"
5559
+ }
5560
+ ),
5561
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "graph-function-name", "data-testid": `graph-function-name-${id}`, children: [
5562
+ name,
5563
+ "(x) ="
5564
+ ] }),
5565
+ /* @__PURE__ */ jsxRuntime.jsx(
5566
+ "input",
5567
+ {
5568
+ "aria-label": "Bi\u1EC3u th\u1EE9c",
5569
+ className: "graph-function-input",
5570
+ type: "text",
5571
+ value: draft,
5572
+ onChange: (e) => setDraft(e.target.value),
5573
+ onKeyDown: handleKeyDown,
5574
+ onBlur: handleBlur,
5575
+ spellCheck: false,
5576
+ autoCorrect: "off",
5577
+ autoCapitalize: "off"
5578
+ }
5579
+ ),
5580
+ /* @__PURE__ */ jsxRuntime.jsx(
5581
+ "button",
5582
+ {
5583
+ type: "button",
5584
+ "aria-label": "\u1EA8n/hi\u1EC7n \u0111\u1ED3 th\u1ECB",
5585
+ className: `graph-function-eye${visible ? "" : " is-hidden"}`,
5586
+ onClick: props.onToggleVisible,
5587
+ children: visible ? "\u{1F441}" : "\u2298"
5588
+ }
5589
+ ),
5590
+ /* @__PURE__ */ jsxRuntime.jsx(
5591
+ "button",
5592
+ {
5593
+ type: "button",
5594
+ "aria-label": "Xo\xE1 \u0111\u1ED3 th\u1ECB",
5595
+ className: "graph-function-remove",
5596
+ onClick: props.onRemove,
5597
+ children: "\u2715"
5598
+ }
5599
+ ),
5600
+ error ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "graph-function-error", children: error }) : null
5601
+ ] });
5602
+ }
5603
+ function SliderRow(props) {
5604
+ const { name, value, min, max, step } = props;
5605
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "graph-slider-row", "data-testid": `graph-slider-row-${name}`, children: [
5606
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "graph-slider-header", children: [
5607
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "graph-slider-name", children: name }),
5608
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "graph-slider-value", children: [
5609
+ "= ",
5610
+ value.toFixed(2)
5611
+ ] }),
5612
+ /* @__PURE__ */ jsxRuntime.jsx(
5613
+ "button",
5614
+ {
5615
+ type: "button",
5616
+ "aria-label": `Xo\xE1 tham s\u1ED1 ${name}`,
5617
+ className: "graph-slider-remove",
5618
+ onClick: props.onRemove,
5619
+ children: "\u2715"
5620
+ }
5621
+ )
5622
+ ] }),
5623
+ /* @__PURE__ */ jsxRuntime.jsx(
5624
+ "input",
5625
+ {
5626
+ type: "range",
5627
+ "aria-label": `Slider ${name}`,
5628
+ min,
5629
+ max,
5630
+ step,
5631
+ value,
5632
+ onChange: (e) => props.onChange(parseFloat(e.target.value)),
5633
+ className: "graph-slider-input"
5634
+ }
5635
+ ),
5636
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "graph-slider-range", children: [
5637
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: min }),
5638
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: max })
5639
+ ] })
5640
+ ] });
5641
+ }
5642
+
5643
+ // src/stamps/graph-2d/colors.ts
5644
+ var GRAPH_PALETTE = [
5645
+ "#2563eb",
5646
+ // blue
5647
+ "#dc2626",
5648
+ // red
5649
+ "#16a34a",
5650
+ // green
5651
+ "#9333ea",
5652
+ // purple
5653
+ "#ea580c",
5654
+ // orange
5655
+ "#0891b2",
5656
+ // cyan
5657
+ "#db2777",
5658
+ // pink
5659
+ "#65a30d"
5660
+ // lime
5661
+ ];
5662
+ var FUNCTION_NAMES = ["f", "g", "h", "i", "j", "k", "l", "m"];
5663
+ var MAX_FUNCTIONS = 8;
5664
+ var MAX_PARAMETERS = 8;
5665
+ function nextColor(usedColors) {
5666
+ for (const c of GRAPH_PALETTE) {
5667
+ if (!usedColors.includes(c)) return c;
5668
+ }
5669
+ return GRAPH_PALETTE[usedColors.length % GRAPH_PALETTE.length];
5670
+ }
5671
+ function nextFunctionName(usedNames) {
5672
+ for (const n of FUNCTION_NAMES) {
5673
+ if (!usedNames.includes(n)) return n;
5674
+ }
5675
+ return FUNCTION_NAMES[usedNames.length % FUNCTION_NAMES.length];
5676
+ }
5677
+ function AlgebraView(props) {
5678
+ const { graph, errors } = props;
5679
+ const atMax = graph.functions.length >= MAX_FUNCTIONS;
5680
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "graph-algebra-view", children: [
5681
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "graph-algebra-section", children: [
5682
+ graph.functions.map((f) => /* @__PURE__ */ jsxRuntime.jsx(
5683
+ FunctionRow,
5684
+ {
5685
+ id: f.id,
5686
+ name: f.name,
5687
+ expression: f.expression,
5688
+ color: f.color,
5689
+ visible: f.visible,
5690
+ error: errors[f.id] ?? null,
5691
+ onExpressionCommit: (expr) => props.onCommitFunctionExpr(f.id, expr),
5692
+ onToggleVisible: () => props.onToggleFunctionVisible(f.id),
5693
+ onRemove: () => props.onRemoveFunction(f.id)
5694
+ },
5695
+ f.id
5696
+ )),
5697
+ /* @__PURE__ */ jsxRuntime.jsx(
5698
+ "button",
5699
+ {
5700
+ type: "button",
5701
+ "aria-label": "Th\xEAm h\xE0m s\u1ED1",
5702
+ className: "graph-algebra-add",
5703
+ onClick: props.onAddFunctionDraft,
5704
+ disabled: atMax,
5705
+ children: "+ Th\xEAm h\xE0m"
5706
+ }
5707
+ )
5708
+ ] }),
5709
+ graph.parameters.length > 0 ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "graph-algebra-section graph-algebra-parameters", children: graph.parameters.map((p) => /* @__PURE__ */ jsxRuntime.jsx(
5710
+ SliderRow,
5711
+ {
5712
+ name: p.name,
5713
+ value: p.value,
5714
+ min: p.min,
5715
+ max: p.max,
5716
+ step: p.step,
5717
+ onChange: (v) => props.onParameterChange(p.name, v),
5718
+ onRangeChange: (min, max, step) => props.onParameterRangeChange(p.name, min, max, step),
5719
+ onRemove: () => props.onRemoveParameter(p.name)
5720
+ },
5721
+ p.name
5722
+ )) }) : null
5723
+ ] });
5724
+ }
5725
+ var GraphIconHeader = /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", children: [
5726
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3 21 V3" }),
5727
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3 21 H21" }),
5728
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M5 19 C8 5, 14 5, 19 17" })
5729
+ ] });
5730
+ function CloseIcon3() {
5731
+ return /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
5732
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
5733
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
5734
+ ] });
5735
+ }
5736
+ function UndoIcon3() {
5737
+ return /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
5738
+ /* @__PURE__ */ jsxRuntime.jsx("polyline", { points: "3 7 3 13 9 13" }),
5739
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3.51 13a9 9 0 1 0 2.13-9.36L3 7" })
5740
+ ] });
5741
+ }
5742
+ function ResetViewIcon2() {
5743
+ return /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
5744
+ /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "12", cy: "12", r: "9" }),
5745
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "12", y1: "3", x2: "12", y2: "21" }),
5746
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "3", y1: "12", x2: "21", y2: "12" })
5747
+ ] });
5748
+ }
5749
+ function MoveIcon() {
5750
+ return /* @__PURE__ */ jsxRuntime.jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3 4 L9 4 L9 9 L4 9 Z" }) });
5751
+ }
5752
+ function PointOnCurveIcon() {
5753
+ return /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round", strokeLinejoin: "round", children: [
5754
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3 17 C7 8, 14 8, 21 14" }),
5755
+ /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "12", cy: "11", r: "2.2", fill: "currentColor", stroke: "none" })
5756
+ ] });
5757
+ }
5758
+ function IntersectIcon() {
5759
+ return /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round", strokeLinejoin: "round", children: [
5760
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3 17 C8 5, 14 5, 21 17" }),
5761
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3 5 C8 17, 14 17, 21 5" }),
5762
+ /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "12", cy: "11", r: "1.6", fill: "currentColor", stroke: "none" })
5763
+ ] });
5764
+ }
5765
+ function TangentIcon() {
5766
+ return /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round", strokeLinejoin: "round", children: [
5767
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3 17 C8 7, 14 7, 21 16" }),
5768
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "4", y1: "14", x2: "20", y2: "6" }),
5769
+ /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "12", cy: "10", r: "1.8", fill: "currentColor", stroke: "none" })
5770
+ ] });
5771
+ }
5772
+ var TOOL_ICONS = {
5773
+ move: /* @__PURE__ */ jsxRuntime.jsx(MoveIcon, {}),
5774
+ "point-on-curve": /* @__PURE__ */ jsxRuntime.jsx(PointOnCurveIcon, {}),
5775
+ intersect: /* @__PURE__ */ jsxRuntime.jsx(IntersectIcon, {}),
5776
+ tangent: /* @__PURE__ */ jsxRuntime.jsx(TangentIcon, {})
5777
+ };
5778
+ function Section4({ label, children }) {
5779
+ return /* @__PURE__ */ jsxRuntime.jsxs("section", { children: [
5780
+ /* @__PURE__ */ jsxRuntime.jsx("h4", { className: "mb-1.5 text-[10px] font-semibold uppercase tracking-wider text-slate-500", children: label }),
5781
+ children
5782
+ ] });
5783
+ }
5784
+ function PanelBody(props) {
5785
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
5786
+ /* @__PURE__ */ jsxRuntime.jsx(Section4, { label: "B\u1ED1 c\u1EE5c", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2 flex-wrap text-[11px] text-slate-700", children: [
5787
+ /* @__PURE__ */ jsxRuntime.jsxs("label", { className: "inline-flex select-none items-center gap-1.5", children: [
5788
+ /* @__PURE__ */ jsxRuntime.jsx(
5789
+ "input",
5790
+ {
5791
+ type: "checkbox",
5792
+ checked: props.showAxis,
5793
+ onChange: (e) => props.onShowAxisChange(e.target.checked),
5794
+ "data-testid": "toggle-axis"
5795
+ }
5796
+ ),
5797
+ "Tr\u1EE5c"
5798
+ ] }),
5799
+ /* @__PURE__ */ jsxRuntime.jsxs("label", { className: "inline-flex select-none items-center gap-1.5", children: [
5800
+ /* @__PURE__ */ jsxRuntime.jsx(
5801
+ "input",
5802
+ {
5803
+ type: "checkbox",
5804
+ checked: props.showGrid,
5805
+ onChange: (e) => props.onShowGridChange(e.target.checked),
5806
+ "data-testid": "toggle-grid"
5807
+ }
5808
+ ),
5809
+ "L\u01B0\u1EDBi"
5810
+ ] }),
5811
+ /* @__PURE__ */ jsxRuntime.jsx(
5812
+ "button",
5813
+ {
5814
+ type: "button",
5815
+ onClick: props.onResetView,
5816
+ title: "\u0110\u1EB7t l\u1EA1i t\u1EA7m nh\xECn",
5817
+ "aria-label": "\u0110\u1EB7t l\u1EA1i t\u1EA7m nh\xECn",
5818
+ className: "ml-auto inline-flex items-center justify-center rounded p-1 text-slate-600 transition hover:bg-slate-100 hover:text-slate-900",
5819
+ children: /* @__PURE__ */ jsxRuntime.jsx(ResetViewIcon2, {})
5820
+ }
5821
+ ),
5822
+ /* @__PURE__ */ jsxRuntime.jsx(
5823
+ "button",
5824
+ {
5825
+ type: "button",
5826
+ onClick: props.onUndo,
5827
+ disabled: !props.canUndo,
5828
+ title: "Ho\xE0n t\xE1c (Ctrl/Cmd+Z)",
5829
+ "aria-label": "Ho\xE0n t\xE1c",
5830
+ className: "inline-flex items-center justify-center rounded p-1 text-slate-600 transition hover:bg-slate-100 hover:text-slate-900 disabled:cursor-not-allowed disabled:text-slate-300 disabled:hover:bg-transparent",
5831
+ children: /* @__PURE__ */ jsxRuntime.jsx(UndoIcon3, {})
5832
+ }
5833
+ )
5834
+ ] }) }),
5835
+ /* @__PURE__ */ jsxRuntime.jsx(Section4, { label: "C\xF4ng c\u1EE5", children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "grid grid-cols-4 gap-1", children: GRAPH_TOOLS.map((t) => {
5836
+ const isActive = props.activeTool === t.id;
5837
+ return /* @__PURE__ */ jsxRuntime.jsx(
5838
+ "button",
5839
+ {
5840
+ type: "button",
5841
+ "aria-label": t.title,
5842
+ title: t.title,
5843
+ "aria-pressed": isActive,
5844
+ onClick: () => props.onToolChange(t.id),
5845
+ "data-testid": `graph-tool-${t.id}`,
5846
+ className: [
5847
+ "flex h-8 items-center justify-center rounded-md transition",
5848
+ isActive ? "bg-orange-600 text-white shadow-sm" : "text-slate-700 hover:bg-slate-100 hover:text-slate-900"
5849
+ ].join(" "),
5850
+ children: TOOL_ICONS[t.id]
5851
+ },
5852
+ t.id
5853
+ );
5854
+ }) }) }),
5855
+ /* @__PURE__ */ jsxRuntime.jsx(Section4, { label: "H\xE0m s\u1ED1", children: /* @__PURE__ */ jsxRuntime.jsx(
5856
+ AlgebraView,
5857
+ {
5858
+ graph: props.graph,
5859
+ errors: props.errors,
5860
+ onAddFunctionDraft: props.onAddFunctionDraft,
5861
+ onCommitFunctionExpr: props.onCommitFunctionExpr,
5862
+ onToggleFunctionVisible: props.onToggleFunctionVisible,
5863
+ onRemoveFunction: props.onRemoveFunction,
5864
+ onParameterChange: props.onParameterChange,
5865
+ onParameterRangeChange: props.onParameterRangeChange,
5866
+ onRemoveParameter: props.onRemoveParameter
5867
+ }
5868
+ ) })
5869
+ ] });
5870
+ }
5871
+ function GraphLeftPanel(props) {
5872
+ const { isMobile, drawerOpen, isDark, onClose, onDrawerClose } = props;
5873
+ if (isMobile && !drawerOpen) return null;
5874
+ const handleClose = isMobile ? onDrawerClose : onClose;
5875
+ return /* @__PURE__ */ jsxRuntime.jsxs(
5876
+ "aside",
5877
+ {
5878
+ role: "complementary",
5879
+ "aria-label": "\u0110\u1ED3 th\u1ECB 2D",
5880
+ "data-testid": "graph-left-panel",
5881
+ "data-stamp-area": "true",
5882
+ className: [
5883
+ isDark ? "theme--dark " : "",
5884
+ isMobile ? "fixed inset-y-0 left-0 z-50 flex w-72 max-w-[85vw] flex-col bg-white shadow-2xl animate-in slide-in-from-left duration-200" : "absolute left-0 top-0 z-30 flex h-full w-60 flex-col border-r border-slate-200 bg-white shadow-md animate-in slide-in-from-left duration-200"
5885
+ ].join(" "),
5886
+ children: [
5887
+ /* @__PURE__ */ jsxRuntime.jsxs("header", { className: "flex items-center justify-between border-b border-slate-200 bg-gradient-to-r from-slate-50 to-white px-3 py-2", children: [
5888
+ /* @__PURE__ */ jsxRuntime.jsxs("h3", { className: "flex items-center gap-2 text-sm font-semibold text-slate-800", children: [
5889
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-base leading-none", children: GraphIconHeader }),
5890
+ "\u0110\u1ED3 th\u1ECB 2D"
5891
+ ] }),
5892
+ /* @__PURE__ */ jsxRuntime.jsx(
5893
+ "button",
5894
+ {
5895
+ onClick: handleClose,
5896
+ "aria-label": "\u0110\xF3ng",
5897
+ className: "rounded p-1 text-slate-500 transition hover:bg-slate-100 hover:text-slate-800",
5898
+ children: /* @__PURE__ */ jsxRuntime.jsx(CloseIcon3, {})
5899
+ }
5900
+ )
5901
+ ] }),
5902
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-3 space-y-4", children: /* @__PURE__ */ jsxRuntime.jsx(PanelBody, { ...props }) })
5903
+ ]
5904
+ }
5905
+ );
5906
+ }
5907
+
5908
+ // src/stamps/graph-2d/parser.ts
5909
+ var ALLOWED_FUNCTIONS = /* @__PURE__ */ new Set([
5910
+ "sin",
5911
+ "cos",
5912
+ "tan",
5913
+ "asin",
5914
+ "acos",
5915
+ "atan",
5916
+ "log",
5917
+ "ln",
5918
+ "exp",
5919
+ "sqrt",
5920
+ "abs",
5921
+ "floor",
5922
+ "ceil",
5923
+ "round"
5924
+ ]);
5925
+ var ALLOWED_CHARS = /^[a-zA-Z0-9_.+\-*/^()\s,]+$/;
5926
+ var IDENTIFIER_RE = /[a-zA-Z][a-zA-Z0-9_]*/g;
5927
+ var SUGGESTIONS = {
5928
+ tg: "tan",
5929
+ arcsin: "asin",
5930
+ arccos: "acos",
5931
+ arctan: "atan"
5932
+ };
5933
+ function errResult(message) {
5934
+ return { ok: false, error: message, freeVars: /* @__PURE__ */ new Set() };
5935
+ }
5936
+ function validate(expr) {
5937
+ const trimmed = expr.trim();
5938
+ if (!trimmed) return errResult("Bi\u1EC3u th\u1EE9c r\u1ED7ng");
5939
+ if (!ALLOWED_CHARS.test(trimmed)) return errResult("K\xFD t\u1EF1 kh\xF4ng h\u1EE3p l\u1EC7");
5940
+ const ids = trimmed.match(IDENTIFIER_RE) ?? [];
5941
+ const freeVars = /* @__PURE__ */ new Set();
5942
+ for (const id of ids) {
5943
+ if (id === "x" || id === "pi" || id === "e") continue;
5944
+ if (ALLOWED_FUNCTIONS.has(id)) continue;
5945
+ if (id.length === 1) {
5946
+ freeVars.add(id);
5947
+ continue;
5948
+ }
5949
+ const hint = SUGGESTIONS[id];
5950
+ return errResult(
5951
+ hint ? `T\xEAn h\xE0m kh\xF4ng h\u1EE3p l\u1EC7: "${id}". B\u1EA1n c\xF3 \xFD l\xE0 "${hint}" kh\xF4ng?` : `T\xEAn kh\xF4ng h\u1EE3p l\u1EC7: "${id}"`
5952
+ );
5953
+ }
5954
+ try {
5955
+ const paramSubs = Object.fromEntries([...freeVars].map((v) => [v, 1]));
5956
+ const rewritten = rewriteToJs(trimmed, paramSubs);
5957
+ new Function("x", `return (${rewritten})`);
5958
+ } catch {
5959
+ return errResult("L\u1ED7i c\xFA ph\xE1p");
5960
+ }
5961
+ return { ok: true, freeVars };
5962
+ }
5963
+ var FUNCTION_REPLACEMENTS = [
5964
+ // longest first để tránh substring conflict (asin trước sin)
5965
+ ["asin", "Math.asin"],
5966
+ ["acos", "Math.acos"],
5967
+ ["atan", "Math.atan"],
5968
+ ["sqrt", "Math.sqrt"],
5969
+ ["floor", "Math.floor"],
5970
+ ["round", "Math.round"],
5971
+ ["ceil", "Math.ceil"],
5972
+ ["sin", "Math.sin"],
5973
+ ["cos", "Math.cos"],
5974
+ ["tan", "Math.tan"],
5975
+ ["abs", "Math.abs"],
5976
+ ["exp", "Math.exp"],
5977
+ ["log", "Math.log10"],
5978
+ ["ln", "Math.log"]
5979
+ ];
5980
+ function rewriteToJs(expr, params) {
5981
+ let s = expr.replace(/\^/g, "**");
5982
+ s = s.replace(/\bpi\b/g, "Math.PI");
5983
+ s = s.replace(/\be\b/g, "Math.E");
5984
+ for (const [from, to] of FUNCTION_REPLACEMENTS) {
5985
+ s = s.replace(new RegExp(`\\b${from}\\b`, "g"), to);
5986
+ }
5987
+ for (const [name, value] of Object.entries(params)) {
5988
+ if (name.length !== 1) continue;
5989
+ s = s.replace(new RegExp(`\\b${name}\\b`, "g"), `(${value})`);
5990
+ }
5991
+ return s;
5992
+ }
5993
+ function compile(expr, paramValues) {
5994
+ const v = validate(expr);
5995
+ if (!v.ok) return { error: v.error ?? "Invalid" };
5996
+ try {
5997
+ const rewritten = rewriteToJs(expr, paramValues);
5998
+ const raw = new Function("x", `return (${rewritten})`);
5999
+ return (x) => {
6000
+ try {
6001
+ const y = raw(x);
6002
+ return typeof y === "number" ? y : NaN;
6003
+ } catch {
6004
+ return NaN;
6005
+ }
6006
+ };
6007
+ } catch (err) {
6008
+ return { error: err instanceof Error ? err.message : String(err) };
6009
+ }
6010
+ }
6011
+
6012
+ // src/stamps/graph-2d/editor/handlers.ts
6013
+ function addPointOnCurve(graph, ctx, idFactory) {
6014
+ if (!ctx.functionId) return graph;
6015
+ const point = {
6016
+ id: idFactory(),
6017
+ functionId: ctx.functionId,
6018
+ x: ctx.x
6019
+ };
6020
+ return { ...graph, points: [...graph.points, point] };
6021
+ }
6022
+ function addIntersection(graph, functionIdA, functionIdB, idFactory) {
6023
+ if (functionIdA === functionIdB) return graph;
6024
+ const exists = graph.intersections.some(
6025
+ (i) => i.functionIdA === functionIdA && i.functionIdB === functionIdB || i.functionIdA === functionIdB && i.functionIdB === functionIdA
6026
+ );
6027
+ if (exists) return graph;
6028
+ const intersection = {
6029
+ id: idFactory(),
6030
+ functionIdA,
6031
+ functionIdB
6032
+ };
6033
+ return { ...graph, intersections: [...graph.intersections, intersection] };
6034
+ }
6035
+ function numericalDerivative(expression, paramValues, x, h = 1e-4) {
6036
+ const fn = compile(expression, paramValues);
6037
+ if (typeof fn !== "function") return NaN;
6038
+ const y1 = fn(x - h);
6039
+ const y2 = fn(x + h);
6040
+ return (y2 - y1) / (2 * h);
6041
+ }
6042
+ function MiniBoard({ graph, activeTool, isDark, onBoardEvent }) {
6043
+ const containerRef = react.useRef(null);
6044
+ const boardRef = react.useRef(null);
6045
+ const curvesRef = react.useRef(/* @__PURE__ */ new Map());
6046
+ react.useEffect(() => {
6047
+ let cancelled = false;
6048
+ let createdBoard = null;
6049
+ const containerEl = containerRef.current;
6050
+ if (!containerEl) return;
6051
+ const containerId = `jxg_graph2d_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
6052
+ containerEl.id = containerId;
6053
+ (async () => {
6054
+ const JXG = (await import('jsxgraph')).default;
6055
+ if (cancelled) return;
6056
+ const opts = JXG.Options;
6057
+ if (opts) {
6058
+ opts.text = opts.text || {};
6059
+ opts.text.display = "internal";
6060
+ opts.label = opts.label || {};
6061
+ opts.label.display = "internal";
6062
+ }
6063
+ const board = JXG.JSXGraph.initBoard(containerId, {
6064
+ boundingbox: [graph.view.xMin, graph.view.yMax, graph.view.xMax, graph.view.yMin],
6065
+ axis: graph.view.showAxis,
6066
+ grid: graph.view.showGrid,
6067
+ showCopyright: false,
6068
+ showNavigation: true,
6069
+ pan: { enabled: true, needShift: false },
6070
+ zoom: { wheel: true, needShift: false },
6071
+ keepAspectRatio: false
6072
+ });
6073
+ boardRef.current = board;
6074
+ createdBoard = board;
6075
+ syncObjects(board, graph, curvesRef.current);
6076
+ board.on("boundingbox", () => {
6077
+ const bb = board.getBoundingBox();
6078
+ onBoardEvent({
6079
+ type: "view-change",
6080
+ view: {
6081
+ xMin: bb[0],
6082
+ xMax: bb[2],
6083
+ yMax: bb[1],
6084
+ yMin: bb[3],
6085
+ showAxis: graph.view.showAxis,
6086
+ showGrid: graph.view.showGrid
6087
+ }
6088
+ });
6089
+ });
6090
+ board.on("down", (ev) => {
6091
+ const usrCoords = board.getUsrCoordsOfMouse?.(ev);
6092
+ const x = usrCoords?.[0] ?? 0;
6093
+ const y = usrCoords?.[1] ?? 0;
6094
+ let functionId;
6095
+ for (const [id, ref] of curvesRef.current) {
6096
+ const obj = ref.obj;
6097
+ if (obj?.hasPoint && obj.hasPoint(ev.clientX ?? 0, ev.clientY ?? 0)) {
6098
+ functionId = id;
6099
+ break;
6100
+ }
6101
+ }
6102
+ if (functionId) onBoardEvent({ type: "click-curve", functionId, x, y });
6103
+ else onBoardEvent({ type: "click-empty", x, y });
6104
+ });
6105
+ })().catch((err) => console.error("MiniBoard init failed:", err));
6106
+ return () => {
6107
+ cancelled = true;
6108
+ try {
6109
+ if (createdBoard) __require("jsxgraph").default.JSXGraph.freeBoard(createdBoard);
6110
+ } catch {
6111
+ }
6112
+ boardRef.current = null;
6113
+ curvesRef.current.clear();
6114
+ };
6115
+ }, []);
6116
+ react.useEffect(() => {
6117
+ if (!boardRef.current) return;
6118
+ syncObjects(boardRef.current, graph, curvesRef.current);
6119
+ }, [graph]);
6120
+ react.useEffect(() => {
6121
+ const el = containerRef.current;
6122
+ if (!el) return;
6123
+ el.style.cursor = activeTool === "move" ? "" : "crosshair";
6124
+ }, [activeTool]);
6125
+ return /* @__PURE__ */ jsxRuntime.jsx(
6126
+ "div",
6127
+ {
6128
+ ref: containerRef,
6129
+ className: "graph-miniboard",
6130
+ style: { width: "100%", height: "100%", minHeight: "300px" },
6131
+ "data-testid": "graph-miniboard"
6132
+ }
6133
+ );
6134
+ }
6135
+ function paramSig(graph) {
6136
+ return graph.parameters.map((p) => `${p.name}=${p.value}`).join(",");
6137
+ }
6138
+ function syncObjects(board, graph, curves) {
6139
+ const sig = paramSig(graph);
6140
+ const paramMap = {};
6141
+ for (const p of graph.parameters) paramMap[p.name] = p.value;
6142
+ const wantedIds = new Set(graph.functions.map((f) => f.id));
6143
+ for (const [id, ref] of curves) {
6144
+ if (!wantedIds.has(id)) {
6145
+ try {
6146
+ board.removeObject(ref.obj);
6147
+ } catch {
6148
+ }
6149
+ curves.delete(id);
6150
+ }
6151
+ }
6152
+ for (const f of graph.functions) {
6153
+ const existing = curves.get(f.id);
6154
+ const needsRecreate = !existing || existing.expression !== f.expression || existing.color !== f.color || existing.visible !== f.visible || existing.paramSignature !== sig;
6155
+ if (!needsRecreate) continue;
6156
+ if (existing) {
6157
+ try {
6158
+ board.removeObject(existing.obj);
6159
+ } catch {
6160
+ }
6161
+ }
6162
+ if (!f.visible) {
6163
+ curves.delete(f.id);
6164
+ continue;
6165
+ }
6166
+ const compiled = compile(f.expression, paramMap);
6167
+ if (typeof compiled !== "function") continue;
6168
+ const domain = f.domain ?? { min: graph.view.xMin, max: graph.view.xMax };
6169
+ const obj = board.create("functiongraph", [compiled, domain.min, domain.max], {
6170
+ strokeColor: f.color,
6171
+ strokeWidth: 2,
6172
+ name: f.name,
6173
+ withLabel: false,
6174
+ highlight: false
6175
+ });
6176
+ curves.set(f.id, {
6177
+ obj,
6178
+ expression: f.expression,
6179
+ color: f.color,
6180
+ visible: f.visible,
6181
+ paramSignature: sig
6182
+ });
6183
+ }
6184
+ for (const point of graph.points) {
6185
+ const fn = graph.functions.find((f) => f.id === point.functionId);
6186
+ if (!fn || !fn.visible) continue;
6187
+ const compiled = compile(fn.expression, paramMap);
6188
+ if (typeof compiled !== "function") continue;
6189
+ const y = compiled(point.x);
6190
+ board.create("point", [point.x, y], {
6191
+ name: point.label ?? "",
6192
+ size: 3,
6193
+ fillColor: fn.color,
6194
+ strokeColor: fn.color,
6195
+ withLabel: !!point.label
6196
+ });
6197
+ }
6198
+ for (const inter of graph.intersections) {
6199
+ const fa = graph.functions.find((f) => f.id === inter.functionIdA);
6200
+ const fb = graph.functions.find((f) => f.id === inter.functionIdB);
6201
+ if (!fa || !fb || !fa.visible || !fb.visible) continue;
6202
+ const cfa = compile(fa.expression, paramMap);
6203
+ const cfb = compile(fb.expression, paramMap);
6204
+ if (typeof cfa !== "function" || typeof cfb !== "function") continue;
6205
+ const roots = scanRoots((x) => cfa(x) - cfb(x), graph.view.xMin, graph.view.xMax);
6206
+ for (const x of roots) {
6207
+ board.create("point", [x, cfa(x)], {
6208
+ size: 3,
6209
+ fillColor: "#000",
6210
+ strokeColor: "#000"
6211
+ });
6212
+ }
6213
+ }
6214
+ for (const tan of graph.tangents) {
6215
+ const pt = graph.points.find((p) => p.id === tan.pointId);
6216
+ if (!pt) continue;
6217
+ const fn = graph.functions.find((f) => f.id === pt.functionId);
6218
+ if (!fn || !fn.visible) continue;
6219
+ const slope = numericalDerivative(fn.expression, paramMap, pt.x);
6220
+ const cfn = compile(fn.expression, paramMap);
6221
+ if (typeof cfn !== "function" || !Number.isFinite(slope)) continue;
6222
+ const y0 = cfn(pt.x);
6223
+ const x1 = graph.view.xMin;
6224
+ const x2 = graph.view.xMax;
6225
+ board.create(
6226
+ "line",
6227
+ [
6228
+ [x1, slope * (x1 - pt.x) + y0],
6229
+ [x2, slope * (x2 - pt.x) + y0]
6230
+ ],
6231
+ {
6232
+ strokeColor: fn.color,
6233
+ strokeWidth: 1,
6234
+ dash: 2,
6235
+ straightFirst: false,
6236
+ straightLast: false
6237
+ }
6238
+ );
6239
+ }
6240
+ board.update();
6241
+ }
6242
+ function scanRoots(fn, xMin, xMax, samples = 200) {
6243
+ const roots = [];
6244
+ const step = (xMax - xMin) / samples;
6245
+ let prevX = xMin;
6246
+ let prevY = fn(prevX);
6247
+ for (let i = 1; i <= samples; i++) {
6248
+ const x = xMin + i * step;
6249
+ const y = fn(x);
6250
+ if (Number.isFinite(prevY) && Number.isFinite(y) && prevY * y < 0) {
6251
+ let a = prevX;
6252
+ let b = x;
6253
+ let ya = prevY;
6254
+ for (let j = 0; j < 30; j++) {
6255
+ const m = (a + b) / 2;
6256
+ const ym = fn(m);
6257
+ if (Math.abs(ym) < 1e-6) {
6258
+ a = b = m;
6259
+ break;
6260
+ }
6261
+ if (ya * ym < 0) {
6262
+ b = m;
6263
+ } else {
6264
+ a = m;
6265
+ ya = ym;
6266
+ }
6267
+ }
6268
+ roots.push((a + b) / 2);
6269
+ }
6270
+ prevX = x;
6271
+ prevY = y;
6272
+ }
6273
+ return roots;
6274
+ }
6275
+
6276
+ // src/stamps/graph-2d/serialize.ts
6277
+ var EMPTY_GRAPH = {
6278
+ version: 1,
6279
+ view: { xMin: -10, xMax: 10, yMin: -10, yMax: 10, showAxis: true, showGrid: true },
6280
+ functions: [],
6281
+ parameters: [],
6282
+ points: [],
6283
+ intersections: [],
6284
+ tangents: []
6285
+ };
6286
+ function stringifySerializedGraph(graph) {
6287
+ return JSON.stringify(graph);
6288
+ }
6289
+ function parseSerializedGraph(jsonState) {
6290
+ let raw;
6291
+ try {
6292
+ raw = JSON.parse(jsonState);
6293
+ } catch {
6294
+ return null;
6295
+ }
6296
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
6297
+ const r = raw;
6298
+ if (r.version !== 1) return null;
6299
+ if (!r.view || typeof r.view !== "object") return null;
6300
+ const v = r.view;
6301
+ if (typeof v.xMin !== "number" || typeof v.xMax !== "number" || typeof v.yMin !== "number" || typeof v.yMax !== "number" || typeof v.showAxis !== "boolean" || typeof v.showGrid !== "boolean") {
6302
+ return null;
6303
+ }
6304
+ for (const key of ["functions", "parameters", "points", "intersections", "tangents"]) {
6305
+ if (!Array.isArray(r[key])) return null;
6306
+ }
6307
+ return raw;
6308
+ }
6309
+
6310
+ // src/stamps/graph-2d/renderObjects.ts
6311
+ function renderGraphObjects(board, graph) {
6312
+ const paramMap = {};
6313
+ for (const p of graph.parameters) paramMap[p.name] = p.value;
6314
+ for (const f of graph.functions) {
6315
+ if (!f.visible) continue;
6316
+ const compiled = compile(f.expression, paramMap);
6317
+ if (typeof compiled !== "function") continue;
6318
+ const domain = f.domain ?? { min: graph.view.xMin, max: graph.view.xMax };
6319
+ board.create("functiongraph", [compiled, domain.min, domain.max], {
6320
+ strokeColor: f.color,
6321
+ strokeWidth: 2,
6322
+ name: f.name,
6323
+ withLabel: false,
6324
+ highlight: false
6325
+ });
6326
+ }
6327
+ for (const point of graph.points) {
6328
+ const fn = graph.functions.find((f) => f.id === point.functionId);
6329
+ if (!fn || !fn.visible) continue;
6330
+ const compiled = compile(fn.expression, paramMap);
6331
+ if (typeof compiled !== "function") continue;
6332
+ const y = compiled(point.x);
6333
+ board.create("point", [point.x, y], {
6334
+ name: point.label ?? "",
6335
+ size: 3,
6336
+ fillColor: fn.color,
6337
+ strokeColor: fn.color,
6338
+ withLabel: !!point.label
6339
+ });
6340
+ }
6341
+ for (const inter of graph.intersections) {
6342
+ const fa = graph.functions.find((f) => f.id === inter.functionIdA);
6343
+ const fb = graph.functions.find((f) => f.id === inter.functionIdB);
6344
+ if (!fa || !fb || !fa.visible || !fb.visible) continue;
6345
+ const cfa = compile(fa.expression, paramMap);
6346
+ const cfb = compile(fb.expression, paramMap);
6347
+ if (typeof cfa !== "function" || typeof cfb !== "function") continue;
6348
+ const roots = scanRoots2((x) => cfa(x) - cfb(x), graph.view.xMin, graph.view.xMax);
6349
+ for (const x of roots) {
6350
+ board.create("point", [x, cfa(x)], {
6351
+ size: 3,
6352
+ fillColor: "#000",
6353
+ strokeColor: "#000"
6354
+ });
6355
+ }
6356
+ }
6357
+ for (const tan of graph.tangents) {
6358
+ const pt = graph.points.find((p) => p.id === tan.pointId);
6359
+ if (!pt) continue;
6360
+ const fn = graph.functions.find((f) => f.id === pt.functionId);
6361
+ if (!fn || !fn.visible) continue;
6362
+ const slope = numericalDerivative(fn.expression, paramMap, pt.x);
6363
+ const cfn = compile(fn.expression, paramMap);
6364
+ if (typeof cfn !== "function" || !Number.isFinite(slope)) continue;
6365
+ const y0 = cfn(pt.x);
6366
+ const x1 = graph.view.xMin;
6367
+ const x2 = graph.view.xMax;
6368
+ board.create(
6369
+ "line",
6370
+ [
6371
+ [x1, slope * (x1 - pt.x) + y0],
6372
+ [x2, slope * (x2 - pt.x) + y0]
6373
+ ],
6374
+ {
6375
+ strokeColor: fn.color,
6376
+ strokeWidth: 1,
6377
+ dash: 2,
6378
+ straightFirst: false,
6379
+ straightLast: false
6380
+ }
6381
+ );
6382
+ }
6383
+ }
6384
+ function scanRoots2(fn, xMin, xMax, samples = 200) {
6385
+ const roots = [];
6386
+ const step = (xMax - xMin) / samples;
6387
+ let prevX = xMin;
6388
+ let prevY = fn(prevX);
6389
+ for (let i = 1; i <= samples; i++) {
6390
+ const x = xMin + i * step;
6391
+ const y = fn(x);
6392
+ if (Number.isFinite(prevY) && Number.isFinite(y) && prevY * y < 0) {
6393
+ let a = prevX;
6394
+ let b = x;
6395
+ let ya = prevY;
6396
+ for (let j = 0; j < 30; j++) {
6397
+ const m = (a + b) / 2;
6398
+ const ym = fn(m);
6399
+ if (Math.abs(ym) < 1e-6) {
6400
+ a = b = m;
6401
+ break;
6402
+ }
6403
+ if (ya * ym < 0) {
6404
+ b = m;
6405
+ } else {
6406
+ a = m;
6407
+ ya = ym;
6408
+ }
6409
+ }
6410
+ roots.push((a + b) / 2);
6411
+ }
6412
+ prevX = x;
6413
+ prevY = y;
6414
+ }
6415
+ return roots;
6416
+ }
6417
+
6418
+ // src/stamps/graph-2d/render.ts
6419
+ async function renderGraph2dSvgFromState(jsonState) {
6420
+ const parsed = parseSerializedGraph(jsonState);
6421
+ if (!parsed) throw new Error("renderGraph2dSvgFromState: jsonState corrupt");
6422
+ const JXG = (await import('jsxgraph')).default;
6423
+ const opts = JXG.Options;
6424
+ if (opts) {
6425
+ opts.text = opts.text || {};
6426
+ opts.text.display = "internal";
6427
+ opts.text.useASCIIMathML = false;
6428
+ opts.text.useMathJax = false;
6429
+ opts.text.useKatex = false;
6430
+ opts.label = opts.label || {};
6431
+ opts.label.display = "internal";
6432
+ }
6433
+ const container = document.createElement("div");
6434
+ container.id = `jxg_graph2d_off_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
6435
+ container.style.cssText = "position:absolute;top:-99999px;left:-99999px;width:600px;height:400px;visibility:hidden;pointer-events:none;";
6436
+ document.body.appendChild(container);
6437
+ let board = null;
6438
+ try {
6439
+ board = JXG.JSXGraph.initBoard(container.id, {
6440
+ boundingbox: [parsed.view.xMin, parsed.view.yMax, parsed.view.xMax, parsed.view.yMin],
6441
+ axis: parsed.view.showAxis,
6442
+ grid: parsed.view.showGrid,
6443
+ showCopyright: false,
6444
+ showNavigation: false,
6445
+ keepAspectRatio: false
6446
+ });
6447
+ renderGraphObjects(board, parsed);
6448
+ board.update();
6449
+ const svgEl = container.querySelector("svg");
6450
+ if (!svgEl) throw new Error("renderGraph2dSvgFromState: no svg generated");
6451
+ return svgEl.outerHTML;
6452
+ } finally {
6453
+ try {
6454
+ if (board) JXG.JSXGraph.freeBoard(board);
6455
+ } catch {
6456
+ }
6457
+ if (container.parentNode) container.parentNode.removeChild(container);
6458
+ }
6459
+ }
6460
+ var GraphEditorPanel = react.forwardRef(function GraphEditorPanel2(props, ref) {
6461
+ const initialGraph = props.initialState ?? EMPTY_GRAPH;
6462
+ const graphRef = react.useRef(initialGraph);
6463
+ const [, forceUpdate] = react.useState(0);
6464
+ const [errors, setErrors] = react.useState({});
6465
+ const [tool, setToolState] = react.useState("move");
6466
+ const undoStackRef = react.useRef([]);
6467
+ const idCounterRef = react.useRef(1);
6468
+ const toolRef = react.useRef(tool);
6469
+ toolRef.current = tool;
6470
+ const intersectFirstRef = react.useRef(null);
6471
+ const propsRef = react.useRef(props);
6472
+ propsRef.current = props;
6473
+ const initialGraphNotifiedRef = react.useRef(false);
6474
+ const pushUndo = react.useCallback((g) => {
6475
+ undoStackRef.current.push(g);
6476
+ if (undoStackRef.current.length > 30) undoStackRef.current.shift();
6477
+ }, []);
6478
+ const setErrorsWithNotify = react.useCallback(
6479
+ (updater) => {
6480
+ setErrors((prev) => {
6481
+ const next = updater(prev);
6482
+ propsRef.current.onErrorsChange?.(next);
6483
+ return next;
6484
+ });
6485
+ },
6486
+ []
6487
+ );
6488
+ const notifyStateChange = react.useCallback((g, t) => {
6489
+ propsRef.current.onStateChange({
6490
+ tool: t,
6491
+ showAxis: g.view.showAxis,
6492
+ showGrid: g.view.showGrid,
6493
+ canUndo: undoStackRef.current.length > 0
6494
+ });
6495
+ }, []);
6496
+ const updateGraph = react.useCallback(
6497
+ (mutator) => {
6498
+ const prev = graphRef.current;
6499
+ pushUndo(prev);
6500
+ const next = mutator(prev);
6501
+ graphRef.current = next;
6502
+ notifyStateChange(next, toolRef.current);
6503
+ forceUpdate((n) => n + 1);
6504
+ propsRef.current.onGraphChange?.(next);
6505
+ },
6506
+ [pushUndo, notifyStateChange]
6507
+ );
6508
+ const onBoardEvent = react.useCallback((ev) => {
6509
+ const currentTool = toolRef.current;
6510
+ if (currentTool === "point-on-curve" && ev.type === "click-curve" && ev.functionId && ev.x !== void 0) {
6511
+ updateGraph(
6512
+ (g) => addPointOnCurve(
6513
+ g,
6514
+ { x: ev.x, y: ev.y ?? 0, functionId: ev.functionId },
6515
+ () => `p${idCounterRef.current++}`
6516
+ )
6517
+ );
6518
+ setToolState("move");
6519
+ } else if (currentTool === "intersect" && ev.type === "click-curve" && ev.functionId) {
6520
+ if (!intersectFirstRef.current) {
6521
+ intersectFirstRef.current = ev.functionId;
6522
+ } else {
6523
+ const a = intersectFirstRef.current;
6524
+ const b = ev.functionId;
6525
+ intersectFirstRef.current = null;
6526
+ updateGraph(
6527
+ (g) => addIntersection(g, a, b, () => `i${idCounterRef.current++}`)
6528
+ );
6529
+ setToolState("move");
6530
+ }
6531
+ } else if (currentTool === "tangent" && ev.type === "click-curve" && ev.functionId && ev.x !== void 0) {
6532
+ const pointId = `p${idCounterRef.current++}`;
6533
+ const tangentId = `t${idCounterRef.current++}`;
6534
+ updateGraph((g) => ({
6535
+ ...g,
6536
+ points: [...g.points, { id: pointId, functionId: ev.functionId, x: ev.x }],
6537
+ tangents: [...g.tangents, { id: tangentId, pointId }]
6538
+ }));
6539
+ setToolState("move");
6540
+ }
6541
+ }, [updateGraph]);
6542
+ react.useImperativeHandle(
6543
+ ref,
6544
+ () => ({
6545
+ insert: () => {
6546
+ const g = graphRef.current;
6547
+ if (g.functions.length === 0) return false;
6548
+ const jsonState = stringifySerializedGraph(g);
6549
+ renderGraph2dSvgFromState(jsonState).then((svg) => propsRef.current.onInsert(jsonState, svg)).catch((err) => console.error("Graph2D insert render failed:", err));
6550
+ return true;
6551
+ },
6552
+ hasContent: () => graphRef.current.functions.length > 0,
6553
+ setTool: (t) => {
6554
+ setToolState(t);
6555
+ const g = graphRef.current;
6556
+ propsRef.current.onStateChange({
6557
+ tool: t,
6558
+ showAxis: g.view.showAxis,
6559
+ showGrid: g.view.showGrid,
6560
+ canUndo: undoStackRef.current.length > 0
6561
+ });
6562
+ },
6563
+ setShowAxis: (b) => updateGraph((g) => ({ ...g, view: { ...g.view, showAxis: b } })),
6564
+ setShowGrid: (b) => updateGraph((g) => ({ ...g, view: { ...g.view, showGrid: b } })),
6565
+ resetView: () => updateGraph((g) => ({
6566
+ ...g,
6567
+ view: { ...g.view, xMin: -10, xMax: 10, yMin: -10, yMax: 10 }
6568
+ })),
6569
+ undo: () => {
6570
+ const prev = undoStackRef.current.pop();
6571
+ if (!prev) return;
6572
+ graphRef.current = prev;
6573
+ forceUpdate((n) => n + 1);
6574
+ propsRef.current.onStateChange({
6575
+ tool: toolRef.current,
6576
+ showAxis: prev.view.showAxis,
6577
+ showGrid: prev.view.showGrid,
6578
+ canUndo: undoStackRef.current.length > 0
6579
+ });
6580
+ propsRef.current.onGraphChange?.(prev);
6581
+ },
6582
+ addFunction: (expr) => {
6583
+ const g = graphRef.current;
6584
+ if (g.functions.length >= MAX_FUNCTIONS) {
6585
+ return { ok: false, error: `T\u1ED1i \u0111a ${MAX_FUNCTIONS} h\xE0m` };
6586
+ }
6587
+ const v = validate(expr);
6588
+ if (!v.ok) return { ok: false, error: v.error ?? "Invalid" };
6589
+ const id = `f${idCounterRef.current++}`;
6590
+ const usedNames = g.functions.map((f) => f.name);
6591
+ const usedColors = g.functions.map((f) => f.color);
6592
+ const newFn = {
6593
+ id,
6594
+ name: nextFunctionName(usedNames),
6595
+ expression: expr,
6596
+ color: nextColor(usedColors),
6597
+ visible: true
6598
+ };
6599
+ const usedParamNames = new Set(g.parameters.map((p) => p.name));
6600
+ const newParams = [];
6601
+ for (const varName of v.freeVars) {
6602
+ if (usedParamNames.has(varName)) continue;
6603
+ if (g.parameters.length + newParams.length >= MAX_PARAMETERS) break;
6604
+ newParams.push({ name: varName, value: 1, min: -5, max: 5, step: 0.1 });
6605
+ }
6606
+ updateGraph((prev) => ({
6607
+ ...prev,
6608
+ functions: [...prev.functions, newFn],
6609
+ parameters: [...prev.parameters, ...newParams]
6610
+ }));
6611
+ setErrorsWithNotify((e) => ({ ...e, [id]: null }));
6612
+ return { ok: true, id };
6613
+ },
6614
+ commitFunctionExpression: (id, expr) => {
6615
+ const g = graphRef.current;
6616
+ const v = validate(expr);
6617
+ if (!v.ok) {
6618
+ setErrorsWithNotify((e) => ({ ...e, [id]: v.error ?? "Invalid" }));
6619
+ return;
6620
+ }
6621
+ const usedParamNames = new Set(g.parameters.map((p) => p.name));
6622
+ const newParams = [];
6623
+ for (const varName of v.freeVars) {
6624
+ if (usedParamNames.has(varName)) continue;
6625
+ if (g.parameters.length + newParams.length >= MAX_PARAMETERS) break;
6626
+ newParams.push({ name: varName, value: 1, min: -5, max: 5, step: 0.1 });
6627
+ }
6628
+ updateGraph((prev) => ({
6629
+ ...prev,
6630
+ functions: prev.functions.map(
6631
+ (f) => f.id === id ? { ...f, expression: expr } : f
6632
+ ),
6633
+ parameters: [...prev.parameters, ...newParams]
6634
+ }));
6635
+ setErrorsWithNotify((e) => ({ ...e, [id]: null }));
6636
+ },
6637
+ toggleFunctionVisible: (id) => updateGraph((g) => ({
6638
+ ...g,
6639
+ functions: g.functions.map(
6640
+ (f) => f.id === id ? { ...f, visible: !f.visible } : f
6641
+ )
6642
+ })),
6643
+ removeFunction: (id) => updateGraph((g) => ({
6644
+ ...g,
6645
+ functions: g.functions.filter((f) => f.id !== id)
6646
+ })),
6647
+ // setParameter does NOT push undo — would flood the stack (slider drag)
6648
+ setParameter: (name, value) => {
6649
+ const next = {
6650
+ ...graphRef.current,
6651
+ parameters: graphRef.current.parameters.map(
6652
+ (p) => p.name === name ? { ...p, value } : p
6653
+ )
6654
+ };
6655
+ graphRef.current = next;
6656
+ forceUpdate((n) => n + 1);
6657
+ propsRef.current.onGraphChange?.(next);
6658
+ },
6659
+ setParameterRange: (name, min, max, step) => updateGraph((g) => ({
6660
+ ...g,
6661
+ parameters: g.parameters.map(
6662
+ (p) => p.name === name ? { ...p, min, max, step, value: Math.min(max, Math.max(min, p.value)) } : p
6663
+ )
6664
+ })),
6665
+ removeParameter: (name) => updateGraph((g) => ({
6666
+ ...g,
6667
+ parameters: g.parameters.filter((p) => p.name !== name)
6668
+ })),
6669
+ getGraph: () => graphRef.current,
6670
+ getErrors: () => errors
6671
+ }),
6672
+ // deps: updateGraph stable; errors changes when function errors change; setErrorsWithNotify stable
6673
+ // eslint-disable-next-line react-hooks/exhaustive-deps
6674
+ [updateGraph, errors, setErrorsWithNotify]
6675
+ );
6676
+ react.useEffect(() => {
6677
+ if (!initialGraphNotifiedRef.current) {
6678
+ initialGraphNotifiedRef.current = true;
6679
+ propsRef.current.onGraphChange?.(graphRef.current);
6680
+ }
6681
+ }, []);
6682
+ const graph = graphRef.current;
6683
+ const hasContent = graph.functions.length > 0;
6684
+ const handleInsert = () => {
6685
+ const g = graphRef.current;
6686
+ if (g.functions.length === 0) return;
6687
+ const jsonState = stringifySerializedGraph(g);
6688
+ renderGraph2dSvgFromState(jsonState).then((svg) => propsRef.current.onInsert(jsonState, svg)).catch((err) => console.error("Graph2D insert render failed:", err));
6689
+ };
6690
+ const { isMobile, isDark, withLeftPanel } = props;
6691
+ const wrapperStyle = isMobile ? { position: "fixed", inset: 0, zIndex: 40 } : {
6692
+ position: "absolute",
6693
+ top: "50%",
6694
+ left: withLeftPanel ? "calc(50% + 120px)" : "50%",
6695
+ transform: "translate(-50%, -50%)",
6696
+ zIndex: 40
6697
+ };
6698
+ return /* @__PURE__ */ jsxRuntime.jsxs(
6699
+ "div",
6700
+ {
6701
+ role: "dialog",
6702
+ "aria-label": "\u0110\u1ED3 th\u1ECB 2D",
6703
+ "data-testid": "graph-editor-panel",
6704
+ "data-stamp-area": "true",
6705
+ "data-mobile-editor": isMobile ? "true" : void 0,
6706
+ style: wrapperStyle,
6707
+ className: [
6708
+ isDark ? "theme--dark " : "",
6709
+ "flex flex-col overflow-hidden bg-white",
6710
+ isMobile ? "h-full w-full" : "h-[540px] max-h-[85vh] w-[640px] max-w-[calc(100vw-280px)] rounded-lg border border-slate-300 shadow-2xl ring-1 ring-black/5"
6711
+ ].join(" "),
6712
+ children: [
6713
+ /* @__PURE__ */ jsxRuntime.jsxs("header", { className: "flex items-center gap-2 border-b border-slate-200 bg-gradient-to-r from-orange-500 to-amber-600 px-3 py-2 text-white", children: [
6714
+ isMobile && /* @__PURE__ */ jsxRuntime.jsx(
6715
+ "button",
6716
+ {
6717
+ type: "button",
6718
+ onClick: props.onOpenDrawer,
6719
+ "aria-label": "M\u1EDF b\u1EA3ng \u0111\u1EA1i s\u1ED1",
6720
+ "data-testid": "graph-drawer-toggle",
6721
+ className: "-ml-1 inline-flex h-10 w-10 items-center justify-center rounded transition hover:bg-white/15",
6722
+ children: /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
6723
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "4", y1: "6", x2: "20", y2: "6" }),
6724
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "4", y1: "12", x2: "20", y2: "12" }),
6725
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "4", y1: "18", x2: "20", y2: "18" })
6726
+ ] })
6727
+ }
6728
+ ),
6729
+ /* @__PURE__ */ jsxRuntime.jsxs("h3", { className: "flex flex-1 items-center gap-2 text-sm font-semibold", children: [
6730
+ /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
6731
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3 21 V3" }),
6732
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3 21 H21" }),
6733
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M5 19 C8 5, 14 5, 19 17" })
6734
+ ] }),
6735
+ "\u0110\u1ED3 th\u1ECB 2D"
6736
+ ] }),
6737
+ isMobile && /* @__PURE__ */ jsxRuntime.jsx(
6738
+ "button",
6739
+ {
6740
+ type: "button",
6741
+ onClick: handleInsert,
6742
+ disabled: !hasContent,
6743
+ "data-testid": "graph-insert-btn-mobile",
6744
+ className: "rounded bg-white/15 px-3 py-1.5 text-xs font-semibold transition hover:bg-white/25 disabled:opacity-50",
6745
+ children: "Ch\xE8n"
6746
+ }
6747
+ ),
6748
+ /* @__PURE__ */ jsxRuntime.jsx(
6749
+ "button",
6750
+ {
6751
+ onClick: props.onClose,
6752
+ "aria-label": "\u0110\xF3ng",
6753
+ className: "inline-flex h-9 w-9 items-center justify-center rounded transition hover:bg-white/15",
6754
+ children: /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
6755
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
6756
+ /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
6757
+ ] })
6758
+ }
6759
+ )
6760
+ ] }),
6761
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "min-h-0 flex-1", children: /* @__PURE__ */ jsxRuntime.jsx(
6762
+ MiniBoard,
6763
+ {
6764
+ graph,
6765
+ activeTool: tool,
6766
+ isDark,
6767
+ onBoardEvent
6768
+ }
6769
+ ) }),
6770
+ !isMobile && /* @__PURE__ */ jsxRuntime.jsxs("footer", { className: "flex items-center justify-between border-t border-slate-200 bg-slate-50 px-3 py-2", children: [
6771
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-xs text-slate-500", children: "Nh\u1EADp bi\u1EC3u th\u1EE9c trong b\u1EA3ng \u0111\u1EA1i s\u1ED1 b\xEAn tr\xE1i." }),
6772
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex gap-2", children: [
6773
+ /* @__PURE__ */ jsxRuntime.jsx(
6774
+ "button",
6775
+ {
6776
+ onClick: props.onClose,
6777
+ className: "rounded border border-slate-300 bg-white px-3 py-1 text-xs font-medium text-slate-700 transition hover:bg-slate-100",
6778
+ children: "Hu\u1EF7"
6779
+ }
6780
+ ),
6781
+ /* @__PURE__ */ jsxRuntime.jsx(
6782
+ "button",
6783
+ {
6784
+ onClick: handleInsert,
6785
+ disabled: !hasContent,
6786
+ "data-testid": "graph-insert-btn",
6787
+ className: "rounded bg-orange-600 px-3 py-1 text-xs font-medium text-white transition hover:bg-orange-700 disabled:opacity-50",
6788
+ children: "Ch\xE8n"
6789
+ }
6790
+ )
6791
+ ] })
6792
+ ] })
6793
+ ]
6794
+ }
6795
+ );
6796
+ });
6797
+ function isGraph2DCustomData(data) {
6798
+ if (!data || typeof data !== "object") return false;
6799
+ const d = data;
6800
+ return d.kind === "graph2d" && d.version === 1 && typeof d.jsonState === "string";
4577
6801
  }
4578
- var Geometry3DStampHost = react.forwardRef(
4579
- function Geometry3DStampHost2({ api, editingElement, onClose, isDark }, ref) {
4580
- const editorRef = react.useRef(null);
4581
- const initial = react.useMemo(
4582
- () => parseInitial(editingElement),
4583
- [editingElement]
6802
+ var INITIAL_GRAPH_STATE = {
6803
+ tool: "move",
6804
+ showAxis: true,
6805
+ showGrid: true,
6806
+ canUndo: false
6807
+ };
6808
+ var Graph2DStampHost = react.forwardRef(
6809
+ function Graph2DStampHost2({ api, editingElement, onClose, isDark }, ref) {
6810
+ const panelRef = react.useRef(null);
6811
+ const [graphUIState, setGraphUIState] = react.useState(INITIAL_GRAPH_STATE);
6812
+ const { isMobile } = useIsMobile();
6813
+ const [drawerOpen, setDrawerOpen] = react.useState(false);
6814
+ const initialState = react.useMemo(() => {
6815
+ if (!editingElement) return null;
6816
+ if (!isGraph2DCustomData(editingElement.customData)) return null;
6817
+ return parseSerializedGraph(editingElement.customData.jsonState);
6818
+ }, [editingElement]);
6819
+ const [graphSnapshot, setGraphSnapshot] = react.useState(
6820
+ initialState ?? EMPTY_GRAPH
4584
6821
  );
6822
+ const [errorsSnapshot, setErrorsSnapshot] = react.useState({});
4585
6823
  const handleInsert = react.useCallback(
4586
- async (jsonState, svgString, width, height) => {
6824
+ async (jsonState, svgString) => {
4587
6825
  if (!api) return;
4588
- await insertStampImage(api, {
4589
- svgString,
4590
- makeCustomData: () => ({
4591
- kind: "geometry3d",
4592
- version: 1,
4593
- jsonState,
4594
- svgWidth: width,
4595
- svgHeight: height
4596
- }),
4597
- editingElementId: editingElement?.id ?? null
4598
- });
6826
+ try {
6827
+ await insertStampImage(api, {
6828
+ svgString,
6829
+ makeCustomData: (width, height) => ({
6830
+ kind: "graph2d",
6831
+ version: 1,
6832
+ jsonState,
6833
+ svgWidth: width,
6834
+ svgHeight: height
6835
+ }),
6836
+ editingElementId: editingElement?.id ?? null
6837
+ });
6838
+ } catch (err) {
6839
+ console.error("Graph2D insert failed:", err);
6840
+ }
4599
6841
  onClose();
4600
6842
  },
4601
- [api, editingElement, onClose]
6843
+ [api, editingElement?.id, onClose]
4602
6844
  );
4603
6845
  react.useImperativeHandle(
4604
6846
  ref,
4605
6847
  () => ({
4606
- tryInsert: () => editorRef.current?.tryInsert() ?? false,
4607
- hasContent: () => editorRef.current?.hasContent() ?? false
6848
+ tryInsert: () => panelRef.current?.insert() ?? false,
6849
+ hasContent: () => panelRef.current?.hasContent() ?? false
4608
6850
  }),
4609
6851
  []
4610
6852
  );
4611
- return /* @__PURE__ */ jsxRuntime.jsx(
4612
- EditorPanel,
4613
- {
4614
- ref: editorRef,
4615
- isDark,
4616
- initial,
4617
- onInsert: handleInsert,
4618
- onClose
4619
- }
4620
- );
6853
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
6854
+ /* @__PURE__ */ jsxRuntime.jsx(
6855
+ GraphLeftPanel,
6856
+ {
6857
+ activeTool: graphUIState.tool,
6858
+ onToolChange: (t) => panelRef.current?.setTool(t),
6859
+ showAxis: graphUIState.showAxis,
6860
+ showGrid: graphUIState.showGrid,
6861
+ onShowAxisChange: (b) => panelRef.current?.setShowAxis(b),
6862
+ onShowGridChange: (b) => panelRef.current?.setShowGrid(b),
6863
+ onResetView: () => panelRef.current?.resetView(),
6864
+ onUndo: () => panelRef.current?.undo(),
6865
+ canUndo: graphUIState.canUndo,
6866
+ onClose,
6867
+ isDark,
6868
+ isMobile,
6869
+ drawerOpen,
6870
+ onDrawerClose: () => setDrawerOpen(false),
6871
+ graph: graphSnapshot,
6872
+ errors: errorsSnapshot,
6873
+ onAddFunctionDraft: () => {
6874
+ const result = panelRef.current?.addFunction("x");
6875
+ if (result && !result.ok) console.warn("addFunction failed:", result.error);
6876
+ },
6877
+ onCommitFunctionExpr: (id, expr) => panelRef.current?.commitFunctionExpression(id, expr),
6878
+ onToggleFunctionVisible: (id) => panelRef.current?.toggleFunctionVisible(id),
6879
+ onRemoveFunction: (id) => panelRef.current?.removeFunction(id),
6880
+ onParameterChange: (name, v) => panelRef.current?.setParameter(name, v),
6881
+ onParameterRangeChange: (name, min, max, step) => panelRef.current?.setParameterRange(name, min, max, step),
6882
+ onRemoveParameter: (name) => panelRef.current?.removeParameter(name)
6883
+ }
6884
+ ),
6885
+ /* @__PURE__ */ jsxRuntime.jsx(
6886
+ GraphEditorPanel,
6887
+ {
6888
+ ref: panelRef,
6889
+ initialState,
6890
+ onInsert: handleInsert,
6891
+ onClose,
6892
+ onStateChange: setGraphUIState,
6893
+ onGraphChange: setGraphSnapshot,
6894
+ onErrorsChange: setErrorsSnapshot,
6895
+ withLeftPanel: !isMobile,
6896
+ isDark,
6897
+ isMobile,
6898
+ onOpenDrawer: () => setDrawerOpen(true)
6899
+ }
6900
+ )
6901
+ ] });
4621
6902
  }
4622
6903
  );
4623
- var Geometry3DIcon = /* @__PURE__ */ jsxRuntime.jsxs(
6904
+ var Graph2DIcon = /* @__PURE__ */ jsxRuntime.jsxs(
4624
6905
  "svg",
4625
6906
  {
4626
6907
  width: "20",
@@ -4633,47 +6914,45 @@ var Geometry3DIcon = /* @__PURE__ */ jsxRuntime.jsxs(
4633
6914
  strokeLinejoin: "round",
4634
6915
  "aria-hidden": "true",
4635
6916
  children: [
4636
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M12 3 L20 8 L20 16 L12 21 L4 16 L4 8 Z" }),
4637
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M12 3 L12 21 M4 8 L12 12 L20 8 M4 16 L12 12 L20 16" })
6917
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3 21 V3" }),
6918
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3 21 H21" }),
6919
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M5 19 C8 5, 14 5, 19 17" })
4638
6920
  ]
4639
6921
  }
4640
6922
  );
4641
- var geometry3dStamp = {
4642
- kind: "geometry3d",
4643
- shortcutKey: "d",
4644
- toolbarLabel: "D",
4645
- toolbarTitle: "H\xECnh 3D (D)",
4646
- toolbarIcon: Geometry3DIcon,
4647
- toolbarTestId: "stamp-toolbar-geometry3d",
4648
- matchesCustomData: isGeometry3DCustomData,
6923
+ var graph2dStamp = {
6924
+ kind: "graph2d",
6925
+ shortcutKey: "h",
6926
+ toolbarLabel: "H",
6927
+ toolbarTitle: "Ch\xE8n \u0111\u1ED3 th\u1ECB 2D (H)",
6928
+ toolbarIcon: Graph2DIcon,
6929
+ toolbarTestId: "stamp-toolbar-graph2d",
6930
+ matchesCustomData: isGraph2DCustomData,
4649
6931
  async renderSvgFromCustomData(data) {
4650
- if (!isGeometry3DCustomData(data)) {
4651
- throw new Error("geometry3dStamp.renderSvgFromCustomData: customData kh\xF4ng ph\u1EA3i geometry3d");
6932
+ if (!isGraph2DCustomData(data)) {
6933
+ throw new Error("graph2dStamp.renderSvgFromCustomData: customData kh\xF4ng ph\u1EA3i graph2d");
4652
6934
  }
4653
- const { svgString } = await renderGeometry3DSvgFromState(data.jsonState);
4654
- return svgString;
6935
+ return renderGraph2dSvgFromState(data.jsonState);
4655
6936
  },
4656
- restoreFileFromCustomData: async (element) => {
6937
+ async restoreFileFromCustomData(element) {
4657
6938
  const data = element.customData;
4658
6939
  const fileId = element.fileId;
4659
6940
  if (!data || !fileId) return null;
4660
- if (!isGeometry3DCustomData(data)) return null;
4661
- try {
4662
- const { svgString } = await renderGeometry3DSvgFromState(data.jsonState);
4663
- const dataURL = `data:image/svg+xml;base64,${typeof btoa !== "undefined" ? btoa(unescape(encodeURIComponent(svgString))) : Buffer.from(svgString).toString("base64")}`;
4664
- return { fileId, dataURL, mimeType: "image/svg+xml" };
4665
- } catch {
4666
- return null;
4667
- }
6941
+ if (!isGraph2DCustomData(data)) return null;
6942
+ const svgString = await renderGraph2dSvgFromState(data.jsonState);
6943
+ const utf8 = unescape(encodeURIComponent(svgString));
6944
+ const dataURL = "data:image/svg+xml;base64," + (typeof btoa !== "undefined" ? btoa(utf8) : Buffer.from(utf8).toString("base64"));
6945
+ return { fileId, dataURL, mimeType: "image/svg+xml" };
4668
6946
  },
4669
- Host: Geometry3DStampHost
6947
+ Host: Graph2DStampHost
4670
6948
  };
4671
6949
 
4672
6950
  // src/stamps/shared/registry.ts
4673
6951
  var DEFAULT_STAMPS = Object.freeze([
4674
6952
  geometryStamp,
4675
6953
  latexStamp,
4676
- geometry3dStamp
6954
+ geometry3dStamp,
6955
+ graph2dStamp
4677
6956
  ]);
4678
6957
  function findStampForCustomData(data, stamps = DEFAULT_STAMPS) {
4679
6958
  for (const s of stamps) {
@@ -4684,36 +6963,90 @@ function findStampForCustomData(data, stamps = DEFAULT_STAMPS) {
4684
6963
  function isStampElement(element, stamps = DEFAULT_STAMPS) {
4685
6964
  return findStampForCustomData(element.customData, stamps) !== null;
4686
6965
  }
4687
- var WRAPPER_ID = "stamp-toolbar-portal-wrapper";
6966
+ var TOOLBAR_WRAPPER_ID = "stamp-toolbar-portal-wrapper";
6967
+ var MENU_WRAPPER_ID = "stamp-menu-portal-wrapper";
4688
6968
  function ToolbarInjector({
4689
6969
  enabled,
4690
6970
  activeStampKind,
4691
6971
  onToggle,
4692
6972
  stamps = DEFAULT_STAMPS
4693
6973
  }) {
4694
- const [mountNode, setMountNode] = react.useState(null);
6974
+ const [isMobile, setIsMobile] = react.useState(false);
6975
+ const [toolbarMount, setToolbarMount] = react.useState(null);
6976
+ const [menuMount, setMenuMount] = react.useState(null);
6977
+ const isMobileRef = react.useRef(false);
6978
+ const toolbarMountRef = react.useRef(null);
6979
+ const menuMountRef = react.useRef(null);
4695
6980
  react.useEffect(() => {
4696
6981
  if (!enabled) {
4697
- setMountNode(null);
6982
+ if (isMobileRef.current !== false) {
6983
+ isMobileRef.current = false;
6984
+ setIsMobile(false);
6985
+ }
6986
+ return;
6987
+ }
6988
+ let cancelled = false;
6989
+ let observer = null;
6990
+ let timer = null;
6991
+ let attempts = 0;
6992
+ const apply = (next) => {
6993
+ if (cancelled || isMobileRef.current === next) return;
6994
+ isMobileRef.current = next;
6995
+ queueMicrotask(() => {
6996
+ if (!cancelled) setIsMobile(next);
6997
+ });
6998
+ };
6999
+ const attach = () => {
7000
+ if (cancelled) return;
7001
+ const root = document.querySelector(".excalidraw");
7002
+ if (!root) {
7003
+ if (attempts++ < 20) timer = setTimeout(attach, 100);
7004
+ return;
7005
+ }
7006
+ apply(root.classList.contains("excalidraw--mobile"));
7007
+ observer = new MutationObserver(() => {
7008
+ apply(root.classList.contains("excalidraw--mobile"));
7009
+ });
7010
+ observer.observe(root, { attributes: true, attributeFilter: ["class"] });
7011
+ };
7012
+ attach();
7013
+ return () => {
7014
+ cancelled = true;
7015
+ if (timer) clearTimeout(timer);
7016
+ observer?.disconnect();
7017
+ };
7018
+ }, [enabled]);
7019
+ react.useEffect(() => {
7020
+ if (!enabled || isMobile) {
7021
+ if (toolbarMountRef.current !== null) {
7022
+ toolbarMountRef.current = null;
7023
+ setToolbarMount(null);
7024
+ }
7025
+ document.getElementById(TOOLBAR_WRAPPER_ID)?.remove();
4698
7026
  return;
4699
7027
  }
4700
7028
  let cancelled = false;
4701
7029
  let attempts = 0;
4702
7030
  let observer = null;
4703
7031
  let timer = null;
7032
+ const apply = (next) => {
7033
+ if (cancelled || toolbarMountRef.current === next) return;
7034
+ toolbarMountRef.current = next;
7035
+ queueMicrotask(() => {
7036
+ if (!cancelled) setToolbarMount(next);
7037
+ });
7038
+ };
4704
7039
  const tryMount = () => {
4705
7040
  if (cancelled) return;
4706
7041
  const container = document.querySelector(".excalidraw .App-toolbar .Stack_horizontal") ?? document.querySelector(".App-toolbar .Stack_horizontal");
4707
7042
  if (!container) {
4708
- if (attempts++ < 20) {
4709
- timer = setTimeout(tryMount, 100);
4710
- }
7043
+ if (attempts++ < 20) timer = setTimeout(tryMount, 100);
4711
7044
  return;
4712
7045
  }
4713
- let wrapper = container.querySelector("#" + WRAPPER_ID);
7046
+ let wrapper = container.querySelector("#" + TOOLBAR_WRAPPER_ID);
4714
7047
  if (!wrapper) {
4715
7048
  wrapper = document.createElement("div");
4716
- wrapper.id = WRAPPER_ID;
7049
+ wrapper.id = TOOLBAR_WRAPPER_ID;
4717
7050
  wrapper.className = "Stamp-toolbar-injector";
4718
7051
  wrapper.setAttribute("data-stamp-area", "true");
4719
7052
  wrapper.style.display = "inline-flex";
@@ -4729,13 +7062,13 @@ function ToolbarInjector({
4729
7062
  container.appendChild(wrapper);
4730
7063
  }
4731
7064
  }
4732
- setMountNode(wrapper);
7065
+ apply(wrapper);
4733
7066
  };
4734
7067
  tryMount();
4735
7068
  const root = document.querySelector(".excalidraw") ?? document.body;
4736
7069
  observer = new MutationObserver(() => {
4737
7070
  if (cancelled) return;
4738
- const stillThere = document.getElementById(WRAPPER_ID);
7071
+ const stillThere = document.getElementById(TOOLBAR_WRAPPER_ID);
4739
7072
  if (!stillThere) {
4740
7073
  attempts = 0;
4741
7074
  tryMount();
@@ -4746,11 +7079,73 @@ function ToolbarInjector({
4746
7079
  cancelled = true;
4747
7080
  if (timer) clearTimeout(timer);
4748
7081
  observer?.disconnect();
4749
- document.getElementById(WRAPPER_ID)?.remove();
7082
+ document.getElementById(TOOLBAR_WRAPPER_ID)?.remove();
4750
7083
  };
4751
- }, [enabled]);
4752
- if (!enabled || !mountNode) return null;
4753
- return reactDom.createPortal(
7084
+ }, [enabled, isMobile]);
7085
+ react.useEffect(() => {
7086
+ if (!enabled || !isMobile) {
7087
+ if (menuMountRef.current !== null) {
7088
+ menuMountRef.current = null;
7089
+ setMenuMount(null);
7090
+ }
7091
+ document.getElementById(MENU_WRAPPER_ID)?.remove();
7092
+ return;
7093
+ }
7094
+ let cancelled = false;
7095
+ let observer = null;
7096
+ let rafId = null;
7097
+ const apply = (next) => {
7098
+ if (cancelled || menuMountRef.current === next) return;
7099
+ menuMountRef.current = next;
7100
+ queueMicrotask(() => {
7101
+ if (!cancelled) setMenuMount(next);
7102
+ });
7103
+ };
7104
+ const findMenu = () => {
7105
+ if (cancelled) return;
7106
+ const container = document.querySelector(
7107
+ ".dropdown-menu--mobile .dropdown-menu-container"
7108
+ );
7109
+ if (!container) {
7110
+ apply(null);
7111
+ return;
7112
+ }
7113
+ let wrapper = container.querySelector("#" + MENU_WRAPPER_ID);
7114
+ if (!wrapper) {
7115
+ wrapper = document.createElement("div");
7116
+ wrapper.id = MENU_WRAPPER_ID;
7117
+ wrapper.setAttribute("data-stamp-menu", "true");
7118
+ wrapper.style.display = "contents";
7119
+ container.insertBefore(wrapper, container.firstChild);
7120
+ }
7121
+ apply(wrapper);
7122
+ };
7123
+ const schedule = () => {
7124
+ if (rafId != null) return;
7125
+ rafId = requestAnimationFrame(() => {
7126
+ rafId = null;
7127
+ findMenu();
7128
+ });
7129
+ };
7130
+ findMenu();
7131
+ const root = document.querySelector(".excalidraw") ?? document.body;
7132
+ observer = new MutationObserver(schedule);
7133
+ observer.observe(root, { childList: true, subtree: true });
7134
+ return () => {
7135
+ cancelled = true;
7136
+ if (rafId != null) cancelAnimationFrame(rafId);
7137
+ observer?.disconnect();
7138
+ document.getElementById(MENU_WRAPPER_ID)?.remove();
7139
+ };
7140
+ }, [enabled, isMobile]);
7141
+ if (!enabled) return null;
7142
+ const closeMobileMenu = () => {
7143
+ const trigger = document.querySelector(
7144
+ ".App-toolbar__extra-tools-trigger"
7145
+ );
7146
+ trigger?.click();
7147
+ };
7148
+ const desktopButtons = !isMobile && toolbarMount ? reactDom.createPortal(
4754
7149
  /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: stamps.map((stamp) => /* @__PURE__ */ jsxRuntime.jsx(
4755
7150
  StampToolButton,
4756
7151
  {
@@ -4763,8 +7158,42 @@ function ToolbarInjector({
4763
7158
  },
4764
7159
  stamp.kind
4765
7160
  )) }),
4766
- mountNode
4767
- );
7161
+ toolbarMount
7162
+ ) : null;
7163
+ const mobileMenuItems = isMobile && menuMount ? reactDom.createPortal(
7164
+ /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
7165
+ stamps.map((stamp) => /* @__PURE__ */ jsxRuntime.jsx(
7166
+ StampMenuItem,
7167
+ {
7168
+ icon: stamp.toolbarIcon,
7169
+ label: stamp.toolbarTitle,
7170
+ active: activeStampKind === stamp.kind,
7171
+ onClick: () => {
7172
+ onToggle(stamp.kind);
7173
+ closeMobileMenu();
7174
+ },
7175
+ dataTestId: stamp.toolbarTestId
7176
+ },
7177
+ stamp.kind
7178
+ )),
7179
+ /* @__PURE__ */ jsxRuntime.jsx(
7180
+ "div",
7181
+ {
7182
+ "aria-hidden": "true",
7183
+ style: {
7184
+ height: 1,
7185
+ background: "var(--default-border-color, rgba(0,0,0,0.08))",
7186
+ margin: "6px 4px"
7187
+ }
7188
+ }
7189
+ )
7190
+ ] }),
7191
+ menuMount
7192
+ ) : null;
7193
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
7194
+ desktopButtons,
7195
+ mobileMenuItems
7196
+ ] });
4768
7197
  }
4769
7198
  function StampToolButton({ icon, keybind, label, active, onClick, dataTestId }) {
4770
7199
  return /* @__PURE__ */ jsxRuntime.jsxs(
@@ -4829,6 +7258,54 @@ function StampToolButton({ icon, keybind, label, active, onClick, dataTestId })
4829
7258
  }
4830
7259
  );
4831
7260
  }
7261
+ function StampMenuItem({ icon, label, active, onClick, dataTestId }) {
7262
+ const className = [
7263
+ "dropdown-menu-item",
7264
+ "dropdown-menu-item-base",
7265
+ active ? "dropdown-menu-item--selected" : ""
7266
+ ].filter(Boolean).join(" ");
7267
+ return /* @__PURE__ */ jsxRuntime.jsxs(
7268
+ "button",
7269
+ {
7270
+ type: "button",
7271
+ onClick,
7272
+ "aria-label": label,
7273
+ "aria-pressed": active,
7274
+ "data-testid": dataTestId,
7275
+ className,
7276
+ style: {
7277
+ display: "flex",
7278
+ alignItems: "center",
7279
+ columnGap: "0.625rem",
7280
+ width: "100%",
7281
+ boxSizing: "border-box",
7282
+ background: "transparent",
7283
+ border: "1px solid transparent",
7284
+ cursor: "pointer",
7285
+ fontFamily: "inherit",
7286
+ fontSize: "0.875rem",
7287
+ color: "var(--color-on-surface)"
7288
+ },
7289
+ children: [
7290
+ /* @__PURE__ */ jsxRuntime.jsx(
7291
+ "span",
7292
+ {
7293
+ "aria-hidden": "true",
7294
+ style: {
7295
+ display: "inline-flex",
7296
+ alignItems: "center",
7297
+ justifyContent: "center",
7298
+ width: "1rem",
7299
+ height: "1rem"
7300
+ },
7301
+ children: icon
7302
+ }
7303
+ ),
7304
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: label })
7305
+ ]
7306
+ }
7307
+ );
7308
+ }
4832
7309
  function isEditableTarget(t) {
4833
7310
  if (!t || !(t instanceof HTMLElement)) return false;
4834
7311
  if (t.isContentEditable) return true;
@@ -5146,7 +7623,9 @@ function Whiteboard({
5146
7623
  stamps = DEFAULT_STAMPS
5147
7624
  }) {
5148
7625
  const [api, setApi] = react.useState(null);
7626
+ const apiRef = react.useRef(null);
5149
7627
  const [isDarkTheme, setIsDarkTheme] = react.useState(false);
7628
+ const isDarkThemeRef = react.useRef(false);
5150
7629
  const knownFileIdsRef = react.useRef(/* @__PURE__ */ new Set());
5151
7630
  const lastSceneHashRef = react.useRef("");
5152
7631
  const sceneThrottleRef = react.useRef(null);
@@ -5207,7 +7686,10 @@ function Whiteboard({
5207
7686
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
5208
7687
  (elements, appState, files) => {
5209
7688
  const nextDark = appState?.theme === "dark";
5210
- setIsDarkTheme((prev) => prev === nextDark ? prev : nextDark);
7689
+ if (isDarkThemeRef.current !== nextDark) {
7690
+ isDarkThemeRef.current = nextDark;
7691
+ queueMicrotask(() => setIsDarkTheme(nextDark));
7692
+ }
5211
7693
  if (readOnly) return;
5212
7694
  latestSceneRef.current = { elements, appState };
5213
7695
  const cropId = appState?.croppingElementId;
@@ -5217,12 +7699,17 @@ function Whiteboard({
5217
7699
  const stamp = findStampForCustomData(el.customData, stamps);
5218
7700
  if (stamp) {
5219
7701
  handledCropIdRef.current = cropId;
5220
- api.updateScene({
5221
- appState: { ...appState, croppingElementId: null, selectedElementIds: {} }
5222
- });
5223
- openStamp(stamp.kind, {
5224
- id: el.id,
5225
- customData: el.customData
7702
+ const elId = el.id;
7703
+ const elCustom = el.customData;
7704
+ const stampKind = stamp.kind;
7705
+ queueMicrotask(() => {
7706
+ try {
7707
+ api.updateScene({
7708
+ appState: { croppingElementId: null, selectedElementIds: {} }
7709
+ });
7710
+ } catch {
7711
+ }
7712
+ openStamp(stampKind, { id: elId, customData: elCustom });
5226
7713
  });
5227
7714
  return;
5228
7715
  }
@@ -5473,8 +7960,12 @@ function Whiteboard({
5473
7960
  Excalidraw2,
5474
7961
  {
5475
7962
  excalidrawAPI: (a) => {
5476
- setApi(a);
5477
- onApi?.(a);
7963
+ if (apiRef.current === a) return;
7964
+ apiRef.current = a;
7965
+ queueMicrotask(() => {
7966
+ setApi(a);
7967
+ onApi?.(a);
7968
+ });
5478
7969
  },
5479
7970
  langCode,
5480
7971
  viewModeEnabled: readOnly,
@@ -5516,8 +8007,10 @@ exports.Whiteboard = Whiteboard;
5516
8007
  exports.findStampForCustomData = findStampForCustomData;
5517
8008
  exports.geometry3dStamp = geometry3dStamp;
5518
8009
  exports.geometryStamp = geometryStamp;
8010
+ exports.graph2dStamp = graph2dStamp;
5519
8011
  exports.isGeometry3DCustomData = isGeometry3DCustomData;
5520
8012
  exports.isGeometryCustomData = isGeometryCustomData;
8013
+ exports.isGraph2DCustomData = isGraph2DCustomData;
5521
8014
  exports.isLatexCustomData = isLatexCustomData;
5522
8015
  exports.isStampElement = isStampElement;
5523
8016
  exports.latexStamp = latexStamp;