@xom11/whiteboard 0.6.2 → 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
@@ -15,6 +15,12 @@ var dynamic__default = /*#__PURE__*/_interopDefault(dynamic);
15
15
 
16
16
  var __defProp = Object.defineProperty;
17
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
+ });
18
24
  var __esm = (fn, res) => function __init() {
19
25
  return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
20
26
  };
@@ -292,6 +298,22 @@ var GROUP_LABELS = {
292
298
  edit: "Ch\u1EC9nh s\u1EEDa",
293
299
  transform: "Ph\xE9p bi\u1EBFn h\xECnh"
294
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
+ }
295
317
  function objKind(obj) {
296
318
  if (!obj) return "other";
297
319
  const e = (obj.elType || obj.type || "").toString().toLowerCase();
@@ -1684,7 +1706,24 @@ var JSXGraphMiniBoard = ({ onReady, initialState, isDark }) => {
1684
1706
  });
1685
1707
  onReady({
1686
1708
  getContainer: () => containerRef.current,
1687
- 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
+ }),
1688
1727
  getBbox: () => boardRef.current ? boardRef.current.getBoundingBox() : [-10, 10, 10, -10],
1689
1728
  getShowAxis: () => showAxisRef.current,
1690
1729
  getShowGrid: () => showGridRef.current,
@@ -1882,8 +1921,140 @@ var JSXGraphMiniBoard = ({ onReady, initialState, isDark }) => {
1882
1921
  }
1883
1922
  );
1884
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
+ }
1885
2056
  var TOOLTIP_DELAY_MS = 400;
1886
- function Shell({ title, icon, onClose, children, isDark }) {
2057
+ function Shell({ title, icon, onClose, children, isDark, closeLabel = "\u0110\xF3ng" }) {
1887
2058
  return /* @__PURE__ */ jsxRuntime.jsxs(
1888
2059
  "aside",
1889
2060
  {
@@ -1891,7 +2062,10 @@ function Shell({ title, icon, onClose, children, isDark }) {
1891
2062
  "aria-label": title,
1892
2063
  "data-testid": "stamp-left-panel",
1893
2064
  "data-stamp-area": "true",
1894
- 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(""),
1895
2069
  children: [
1896
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: [
1897
2071
  /* @__PURE__ */ jsxRuntime.jsxs("h3", { className: "flex items-center gap-2 text-sm font-semibold text-slate-800", children: [
@@ -1902,12 +2076,9 @@ function Shell({ title, icon, onClose, children, isDark }) {
1902
2076
  "button",
1903
2077
  {
1904
2078
  onClick: onClose,
1905
- "aria-label": "\u0110\xF3ng",
2079
+ "aria-label": closeLabel,
1906
2080
  className: "rounded p-1 text-slate-500 transition hover:bg-slate-100 hover:text-slate-800",
1907
- 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: [
1908
- /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
1909
- /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
1910
- ] })
2081
+ children: /* @__PURE__ */ jsxRuntime.jsx(CloseIcon, {})
1911
2082
  }
1912
2083
  )
1913
2084
  ] }),
@@ -1928,24 +2099,36 @@ var GeometryIconHeader = /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "14", h
1928
2099
  /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "20", cy: "20", r: "1.5", fill: "currentColor", stroke: "none" }),
1929
2100
  /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "12", cy: "5", r: "1.5", fill: "currentColor", stroke: "none" })
1930
2101
  ] });
1931
- function LeftPanel({
1932
- activeTool,
1933
- onToolChange,
1934
- showAxis,
1935
- showGrid,
1936
- onShowAxisChange,
1937
- onShowGridChange,
1938
- onUndo,
1939
- canUndo,
1940
- onClose,
1941
- isDark
1942
- }) {
1943
- const grouped = TOOLS.reduce((acc, t) => {
1944
- var _a;
1945
- (acc[_a = t.group] ?? (acc[_a] = [])).push(t);
1946
- return acc;
1947
- }, {});
1948
- 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() {
1949
2132
  const [hover, setHover] = react.useState(null);
1950
2133
  const [portalReady, setPortalReady] = react.useState(false);
1951
2134
  const hoverTimerRef = react.useRef(null);
@@ -1969,6 +2152,23 @@ function LeftPanel({
1969
2152
  }
1970
2153
  setHover(null);
1971
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();
1972
2172
  return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1973
2173
  /* @__PURE__ */ jsxRuntime.jsxs(Shell, { title: "H\xECnh h\u1ECDc", icon: GeometryIconHeader, onClose, isDark, children: [
1974
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: [
@@ -2005,36 +2205,95 @@ function LeftPanel({
2005
2205
  title: "Ho\xE0n t\xE1c (Ctrl/Cmd+Z)",
2006
2206
  "aria-label": "Ho\xE0n t\xE1c",
2007
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",
2008
- 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: [
2009
- /* @__PURE__ */ jsxRuntime.jsx("polyline", { points: "3 7 3 13 9 13" }),
2010
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3.51 13a9 9 0 1 0 2.13-9.36L3 7" })
2011
- ] })
2208
+ children: /* @__PURE__ */ jsxRuntime.jsx(UndoIcon, {})
2012
2209
  }
2013
2210
  )
2014
2211
  ] }) }),
2015
- 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) => {
2016
- const active = activeTool === t.key;
2017
- return /* @__PURE__ */ jsxRuntime.jsx(
2018
- "button",
2212
+ groupKeys.map((group) => {
2213
+ const isChordActive = chordGroup === group;
2214
+ const dimmed = chordGroup !== null && !isChordActive;
2215
+ return /* @__PURE__ */ jsxRuntime.jsxs(
2216
+ "section",
2019
2217
  {
2020
- type: "button",
2021
- "aria-label": t.label,
2022
- "aria-pressed": active,
2023
- "data-tool": t.key,
2024
- onClick: () => onToolChange(t.key),
2025
- onMouseEnter: (e) => showHover(e.currentTarget, t),
2026
- onMouseLeave: hideHover,
2027
- onFocus: (e) => showHover(e.currentTarget, t),
2028
- onBlur: hideHover,
2218
+ "data-chord-group": group,
2219
+ "data-chord-active": isChordActive ? "true" : "false",
2029
2220
  className: [
2030
- "flex h-8 items-center justify-center rounded-md transition",
2031
- 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"
2032
2224
  ].join(" "),
2033
- 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
+ ]
2034
2277
  },
2035
- t.key
2278
+ group
2036
2279
  );
2037
- }) }) }, 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
+ )
2038
2297
  ] }),
2039
2298
  portalReady && hover && typeof document !== "undefined" ? reactDom.createPortal(
2040
2299
  /* @__PURE__ */ jsxRuntime.jsxs(
@@ -2058,6 +2317,78 @@ function LeftPanel({
2058
2317
  ) : null
2059
2318
  ] });
2060
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
+ }
2061
2392
 
2062
2393
  // src/stamps/geometry-2d/serialize.ts
2063
2394
  function serializeBoard(board, log, options = {}) {
@@ -2202,6 +2533,38 @@ var STROKE_PALETTE = [
2202
2533
  "#868e96"
2203
2534
  // gray
2204
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
+ }
2205
2568
  var DASH_OPTIONS = [
2206
2569
  { value: 0, label: "N\xE9t li\u1EC1n" },
2207
2570
  { value: 2, label: "N\xE9t \u0111\u1EE9t" },
@@ -2260,6 +2623,26 @@ var PropertiesPopover = (props) => {
2260
2623
  const { anchor, onClose, onMutate, isDark, getAllNames } = props;
2261
2624
  const rootRef = react.useRef(null);
2262
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]);
2263
2646
  const initialName = props.kind === "point" ? props.currentName : props.kind === "line" || props.kind === "circle" ? props.currentName : "";
2264
2647
  const [name, setName] = react.useState(initialName);
2265
2648
  react.useEffect(() => {
@@ -2272,14 +2655,14 @@ var PropertiesPopover = (props) => {
2272
2655
  onClose();
2273
2656
  }
2274
2657
  };
2275
- const onMouseDown = (e) => {
2658
+ const onPointerDown = (e) => {
2276
2659
  if (!rootRef.current?.contains(e.target)) onClose();
2277
2660
  };
2278
2661
  document.addEventListener("keydown", onKey);
2279
- document.addEventListener("mousedown", onMouseDown, { capture: true });
2662
+ document.addEventListener("pointerdown", onPointerDown, { capture: true });
2280
2663
  return () => {
2281
2664
  document.removeEventListener("keydown", onKey);
2282
- document.removeEventListener("mousedown", onMouseDown, { capture: true });
2665
+ document.removeEventListener("pointerdown", onPointerDown, { capture: true });
2283
2666
  };
2284
2667
  }, [onClose]);
2285
2668
  const pickColor = (c) => {
@@ -2316,6 +2699,7 @@ var PropertiesPopover = (props) => {
2316
2699
  {
2317
2700
  type: "button",
2318
2701
  "data-section": id,
2702
+ "data-pill-btn": id,
2319
2703
  "aria-label": label,
2320
2704
  "aria-pressed": !!active,
2321
2705
  onClick,
@@ -2334,13 +2718,14 @@ var PropertiesPopover = (props) => {
2334
2718
  }
2335
2719
  );
2336
2720
  const colorIndicatorTint = react.useMemo(() => currentColor, [currentColor]);
2721
+ const pos = clamped ?? { left: anchor.x, top: anchor.y };
2337
2722
  const node = /* @__PURE__ */ jsxRuntime.jsxs(
2338
2723
  "div",
2339
2724
  {
2340
2725
  ref: rootRef,
2341
2726
  "data-stamp-area": "true",
2342
2727
  className: `${isDark ? "theme--dark " : ""}fixed z-[2147483600] flex flex-col gap-1.5`,
2343
- style: { left: anchor.x, top: anchor.y },
2728
+ style: { left: pos.left, top: pos.top },
2344
2729
  role: "dialog",
2345
2730
  "aria-label": "Thu\u1ED9c t\xEDnh \u0111\u1ED1i t\u01B0\u1EE3ng",
2346
2731
  children: [
@@ -2530,7 +2915,7 @@ var TransformParamPopover = ({ kind, anchor, defaultValue, onConfirm, onCancel,
2530
2915
  return reactDom.createPortal(node, document.body);
2531
2916
  };
2532
2917
  var GeometryEditorPanel = react.forwardRef(
2533
- function GeometryEditorPanel2({ initialState, onInsert, onClose, withLeftPanel = false, onStateChange, isDark }, ref) {
2918
+ function GeometryEditorPanel2({ initialState, onInsert, onClose, withLeftPanel = false, onStateChange, isDark, isMobile = false, onOpenDrawer }, ref) {
2534
2919
  const handleRef = react.useRef(null);
2535
2920
  const [ready, setReady] = react.useState(false);
2536
2921
  const [propsPopover, setPropsPopover] = react.useState(null);
@@ -2592,7 +2977,7 @@ var GeometryEditorPanel = react.forwardRef(
2592
2977
  insert: performInsert,
2593
2978
  hasContent: () => (handleRef.current?.getCreationLog().length ?? 0) > 0
2594
2979
  }), [performInsert]);
2595
- const wrapperStyle = {
2980
+ const wrapperStyle = isMobile ? { position: "fixed", inset: 0, zIndex: 40 } : {
2596
2981
  position: "absolute",
2597
2982
  top: "50%",
2598
2983
  left: withLeftPanel ? "calc(50% + 120px)" : "50%",
@@ -2606,11 +2991,30 @@ var GeometryEditorPanel = react.forwardRef(
2606
2991
  "aria-label": "D\u1EF1ng h\xECnh h\u1ECDc",
2607
2992
  "data-testid": "geometry-editor-panel",
2608
2993
  "data-stamp-area": "true",
2994
+ "data-mobile-editor": isMobile ? "true" : void 0,
2609
2995
  style: wrapperStyle,
2610
- 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(" "),
2611
3001
  children: [
2612
- /* @__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: [
2613
- /* @__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: [
2614
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: [
2615
3019
  /* @__PURE__ */ jsxRuntime.jsx("polygon", { points: "3,18 12,3 21,18" }),
2616
3020
  /* @__PURE__ */ jsxRuntime.jsx("circle", { cx: "12", cy: "3", r: "1.5", fill: "currentColor" }),
@@ -2619,12 +3023,23 @@ var GeometryEditorPanel = react.forwardRef(
2619
3023
  ] }),
2620
3024
  "D\u1EF1ng h\xECnh h\u1ECDc"
2621
3025
  ] }),
2622
- /* @__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: [
2623
3038
  /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
2624
3039
  /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
2625
3040
  ] }) })
2626
3041
  ] }),
2627
- /* @__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(
2628
3043
  JSXGraphMiniBoard,
2629
3044
  {
2630
3045
  onReady: handleReady,
@@ -2697,7 +3112,7 @@ var GeometryEditorPanel = react.forwardRef(
2697
3112
  }
2698
3113
  }
2699
3114
  ),
2700
- /* @__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: [
2701
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." }),
2702
3117
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex gap-2", children: [
2703
3118
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -2725,6 +3140,76 @@ var GeometryEditorPanel = react.forwardRef(
2725
3140
  );
2726
3141
  }
2727
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
+ }
2728
3213
 
2729
3214
  // src/stamps/shared/svgToImage.ts
2730
3215
  async function hashString(input) {
@@ -2847,6 +3332,14 @@ var GeometryStampHost = react.forwardRef(
2847
3332
  function GeometryStampHost2({ api, editingElement, onClose, isDark }, ref) {
2848
3333
  const panelRef = react.useRef(null);
2849
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
+ });
2850
3343
  const initialState = react.useMemo(() => {
2851
3344
  if (!editingElement) return null;
2852
3345
  if (!isGeometryCustomData(editingElement.customData)) return null;
@@ -2900,7 +3393,11 @@ var GeometryStampHost = react.forwardRef(
2900
3393
  onUndo: () => panelRef.current?.undo(),
2901
3394
  canUndo: geomState.canUndo,
2902
3395
  onClose,
2903
- isDark
3396
+ isDark,
3397
+ isMobile,
3398
+ drawerOpen,
3399
+ onDrawerClose: () => setDrawerOpen(false),
3400
+ chordGroup
2904
3401
  }
2905
3402
  ),
2906
3403
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -2911,8 +3408,10 @@ var GeometryStampHost = react.forwardRef(
2911
3408
  onInsert: handleInsert,
2912
3409
  onClose,
2913
3410
  onStateChange: setGeomState,
2914
- withLeftPanel: true,
2915
- isDark
3411
+ withLeftPanel: !isMobile,
3412
+ isDark,
3413
+ isMobile,
3414
+ onOpenDrawer: () => setDrawerOpen(true)
2916
3415
  }
2917
3416
  )
2918
3417
  ] });
@@ -2950,38 +3449,58 @@ var geometryStamp = {
2950
3449
  },
2951
3450
  Host: GeometryStampHost
2952
3451
  };
2953
- function Shell2({ title, icon, onClose, children }) {
2954
- return /* @__PURE__ */ jsxRuntime.jsxs(
2955
- "aside",
2956
- {
2957
- role: "complementary",
2958
- "aria-label": title,
2959
- "data-testid": "stamp-left-panel",
2960
- "data-stamp-area": "true",
2961
- 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",
2962
- children: [
2963
- /* @__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: [
2964
- /* @__PURE__ */ jsxRuntime.jsxs("h3", { className: "flex items-center gap-2 text-sm font-semibold text-slate-800", children: [
2965
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-base leading-none", children: icon }),
2966
- 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
+ )
2967
3498
  ] }),
2968
- /* @__PURE__ */ jsxRuntime.jsx(
2969
- "button",
2970
- {
2971
- onClick: onClose,
2972
- "aria-label": "\u0110\xF3ng",
2973
- className: "rounded p-1 text-slate-500 transition hover:bg-slate-100 hover:text-slate-800",
2974
- 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: [
2975
- /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
2976
- /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
2977
- ] })
2978
- }
2979
- )
2980
- ] }),
2981
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-3 space-y-4", children })
2982
- ]
2983
- }
2984
- );
3499
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-3 space-y-4", children })
3500
+ ]
3501
+ }
3502
+ )
3503
+ ] });
2985
3504
  }
2986
3505
  function Section2({ label, children }) {
2987
3506
  return /* @__PURE__ */ jsxRuntime.jsxs("section", { children: [
@@ -3028,65 +3547,80 @@ function LeftPanel2({
3028
3547
  displayMode,
3029
3548
  onDisplayModeChange,
3030
3549
  onInsertSnippet,
3031
- onClose
3550
+ onClose,
3551
+ isMobile,
3552
+ drawerOpen,
3553
+ onDrawerClose
3032
3554
  }) {
3033
- return /* @__PURE__ */ jsxRuntime.jsxs(Shell2, { title: "C\xF4ng th\u1EE9c LaTeX", icon: "\u2211", onClose, children: [
3034
- /* @__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: [
3035
- /* @__PURE__ */ jsxRuntime.jsxs(
3036
- "button",
3037
- {
3038
- type: "button",
3039
- onClick: () => onDisplayModeChange(false),
3040
- "aria-pressed": !displayMode,
3041
- className: [
3042
- "rounded-md border px-2 py-1.5 text-xs transition",
3043
- !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"
3044
- ].join(" "),
3045
- children: [
3046
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "block font-medium", children: "Inline" }),
3047
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "block text-[10px] text-slate-500", children: "$ ... $" })
3048
- ]
3049
- }
3050
- ),
3051
- /* @__PURE__ */ jsxRuntime.jsxs(
3052
- "button",
3053
- {
3054
- type: "button",
3055
- onClick: () => onDisplayModeChange(true),
3056
- "aria-pressed": displayMode,
3057
- className: [
3058
- "rounded-md border px-2 py-1.5 text-xs transition",
3059
- 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"
3060
- ].join(" "),
3061
- children: [
3062
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "block font-medium", children: "Block" }),
3063
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "block text-[10px] text-slate-500", children: "$$ ... $$" })
3064
- ]
3065
- }
3066
- )
3067
- ] }) }),
3068
- 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(
3069
- "button",
3070
- {
3071
- type: "button",
3072
- onClick: () => onInsertSnippet(s.snippet),
3073
- title: s.snippet,
3074
- 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",
3075
- children: s.preview
3076
- },
3077
- s.snippet
3078
- )) }) }, group.group)),
3079
- /* @__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: [
3080
- /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "inline-flex items-center gap-1", children: [
3081
- /* @__PURE__ */ jsxRuntime.jsx("kbd", { className: "rounded border border-slate-300 bg-slate-50 px-1.5 py-0.5 font-mono", children: "Enter" }),
3082
- "ch\xE8n"
3083
- ] }),
3084
- /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "inline-flex items-center gap-1", children: [
3085
- /* @__PURE__ */ jsxRuntime.jsx("kbd", { className: "rounded border border-slate-300 bg-slate-50 px-1.5 py-0.5 font-mono", children: "Esc" }),
3086
- "\u0111\xF3ng"
3087
- ] })
3088
- ] }) })
3089
- ] });
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
+ );
3090
3624
  }
3091
3625
 
3092
3626
  // src/stamps/latex/render.ts
@@ -3138,7 +3672,9 @@ var EditorPopover = react.forwardRef(function EditorPopover2({
3138
3672
  onClose,
3139
3673
  displayMode: controlledDisplayMode,
3140
3674
  onDisplayModeChange,
3141
- withLeftPanel = false
3675
+ withLeftPanel = false,
3676
+ isMobile = false,
3677
+ onOpenDrawer
3142
3678
  }, ref) {
3143
3679
  const [value, setValue] = react.useState(initialValue);
3144
3680
  const [internalDisplayMode] = react.useState(false);
@@ -3209,7 +3745,7 @@ var EditorPopover = react.forwardRef(function EditorPopover2({
3209
3745
  [value, previewSvg, error, displayMode, onInsert]
3210
3746
  );
3211
3747
  const isLegacyPosition = x > 0 || y > 0;
3212
- 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 } : {
3213
3749
  position: "absolute",
3214
3750
  top: "50%",
3215
3751
  left: withLeftPanel ? "calc(50% + 120px)" : "50%",
@@ -3221,29 +3757,55 @@ var EditorPopover = react.forwardRef(function EditorPopover2({
3221
3757
  {
3222
3758
  style: wrapperStyle,
3223
3759
  "data-stamp-area": "true",
3224
- 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",
3225
3762
  role: "dialog",
3226
3763
  "aria-label": "Nh\u1EADp c\xF4ng th\u1EE9c LaTeX",
3227
3764
  children: [
3228
- /* @__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: [
3229
- /* @__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: [
3230
3781
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-base leading-none", children: "\u2211" }),
3231
3782
  "C\xF4ng th\u1EE9c LaTeX"
3232
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
+ ),
3233
3795
  /* @__PURE__ */ jsxRuntime.jsx(
3234
3796
  "button",
3235
3797
  {
3236
3798
  onClick: onClose,
3237
3799
  "aria-label": "\u0110\xF3ng",
3238
- className: "rounded p-1 transition hover:bg-white/15",
3239
- 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: [
3240
3802
  /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
3241
3803
  /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
3242
3804
  ] })
3243
3805
  }
3244
3806
  )
3245
3807
  ] }),
3246
- /* @__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: [
3247
3809
  /* @__PURE__ */ jsxRuntime.jsx(
3248
3810
  "input",
3249
3811
  {
@@ -3254,7 +3816,7 @@ var EditorPopover = react.forwardRef(function EditorPopover2({
3254
3816
  onChange: (e) => setValue(e.target.value),
3255
3817
  onKeyDown: handleKeyDown,
3256
3818
  placeholder: "Vd: \\frac{a^2+b^2}{c}",
3257
- 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"}`,
3258
3820
  autoFocus: true
3259
3821
  }
3260
3822
  ),
@@ -3262,7 +3824,8 @@ var EditorPopover = react.forwardRef(function EditorPopover2({
3262
3824
  "div",
3263
3825
  {
3264
3826
  className: [
3265
- "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]",
3266
3829
  error ? "border-rose-300 bg-rose-50 text-rose-700" : "border-slate-200 bg-slate-50"
3267
3830
  ].join(" "),
3268
3831
  children: error ? /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "text-xs", children: [
@@ -3271,7 +3834,7 @@ var EditorPopover = react.forwardRef(function EditorPopover2({
3271
3834
  ] }) : previewSvg ? /* @__PURE__ */ jsxRuntime.jsx("span", { dangerouslySetInnerHTML: { __html: previewSvg } }) : /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-xs text-slate-400", children: "(xem tr\u01B0\u1EDBc)" })
3272
3835
  }
3273
3836
  ),
3274
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center justify-between", children: [
3837
+ !isMobile && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center justify-between", children: [
3275
3838
  /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "text-[11px] text-slate-500", children: [
3276
3839
  displayMode ? "Block" : "Inline",
3277
3840
  " \xB7 Enter \u0111\u1EC3 ch\xE8n"
@@ -3295,6 +3858,10 @@ var EditorPopover = react.forwardRef(function EditorPopover2({
3295
3858
  }
3296
3859
  )
3297
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"
3298
3865
  ] })
3299
3866
  ] })
3300
3867
  ]
@@ -3309,6 +3876,8 @@ function isLatexCustomData(data) {
3309
3876
  var LatexStampHost = react.forwardRef(
3310
3877
  function LatexStampHost2({ api, editingElement, onClose }, ref) {
3311
3878
  const editorRef = react.useRef(null);
3879
+ const { isMobile } = useIsMobile();
3880
+ const [drawerOpen, setDrawerOpen] = react.useState(false);
3312
3881
  const initial = react.useMemo(() => {
3313
3882
  if (editingElement && isLatexCustomData(editingElement.customData)) {
3314
3883
  return {
@@ -3355,7 +3924,10 @@ var LatexStampHost = react.forwardRef(
3355
3924
  displayMode,
3356
3925
  onDisplayModeChange: setDisplayMode,
3357
3926
  onInsertSnippet: (s) => editorRef.current?.insertAtCursor(s),
3358
- onClose
3927
+ onClose,
3928
+ isMobile,
3929
+ drawerOpen,
3930
+ onDrawerClose: () => setDrawerOpen(false)
3359
3931
  }
3360
3932
  ),
3361
3933
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -3369,7 +3941,9 @@ var LatexStampHost = react.forwardRef(
3369
3941
  onDisplayModeChange: setDisplayMode,
3370
3942
  onInsert: handleInsert,
3371
3943
  onClose,
3372
- withLeftPanel: true
3944
+ withLeftPanel: !isMobile,
3945
+ isMobile,
3946
+ onOpenDrawer: () => setDrawerOpen(true)
3373
3947
  }
3374
3948
  )
3375
3949
  ] });
@@ -4001,7 +4575,25 @@ var MiniBoard3D = react.forwardRef(function MiniBoard3D2({ isDark, initialState
4001
4575
  toolRef.current = t;
4002
4576
  notify();
4003
4577
  },
4004
- 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
+ }),
4005
4597
  pushLog: (e) => {
4006
4598
  logRef.current.push(e);
4007
4599
  notify();
@@ -4077,15 +4669,159 @@ var MiniBoard3D = react.forwardRef(function MiniBoard3D2({ isDark, initialState
4077
4669
  }
4078
4670
  );
4079
4671
  });
4080
-
4081
- // src/stamps/geometry-3d/editor/tools.ts
4082
- var GROUP_LABELS_3D = {
4083
- view: "Xem",
4084
- primitive: "C\u01A1 b\u1EA3n",
4085
- solid: "Kh\u1ED1i \u0111a di\u1EC7n",
4086
- curved: "Kh\u1ED1i cong",
4087
- meta: "Kh\xE1c"
4088
- };
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
+ });
4804
+
4805
+ // src/stamps/geometry-3d/editor/tools.ts
4806
+ var GROUP_LABELS_3D = {
4807
+ view: "Xem",
4808
+ primitive: "C\u01A1 b\u1EA3n",
4809
+ solid: "Kh\u1ED1i \u0111a di\u1EC7n",
4810
+ curved: "Kh\u1ED1i cong",
4811
+ meta: "Kh\xE1c"
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
+ }
4089
4825
  var TOOLS_3D = [
4090
4826
  { key: "move", label: "Di chuy\u1EC3n", group: "view", stepsRequired: 0 },
4091
4827
  { key: "point", label: "\u0110i\u1EC3m", group: "primitive", stepsRequired: 1, hint: "Nh\u1EADp (x, y, z)" },
@@ -4139,8 +4875,8 @@ var TOOLS_3D = [
4139
4875
  },
4140
4876
  { key: "label", label: "Nh\xE3n", group: "meta", stepsRequired: 1, hint: "G\u1EAFn v\xE0o \u0111i\u1EC3m" }
4141
4877
  ];
4142
- function ToolButton({ toolKey, label, hint, active, onClick, icon }) {
4143
- return /* @__PURE__ */ jsxRuntime.jsx(
4878
+ function ToolButton({ toolKey, label, hint, active, onClick, icon, badge }) {
4879
+ return /* @__PURE__ */ jsxRuntime.jsxs(
4144
4880
  "button",
4145
4881
  {
4146
4882
  type: "button",
@@ -4151,10 +4887,13 @@ function ToolButton({ toolKey, label, hint, active, onClick, icon }) {
4151
4887
  "data-active": active || void 0,
4152
4888
  "data-tool": toolKey,
4153
4889
  className: [
4154
- "flex h-8 items-center justify-center rounded-md transition",
4890
+ "relative flex h-8 items-center justify-center rounded-md transition",
4155
4891
  active ? "bg-blue-600 text-white shadow-sm" : "text-slate-700 hover:bg-slate-100 hover:text-slate-900"
4156
4892
  ].join(" "),
4157
- children: icon
4893
+ children: [
4894
+ icon,
4895
+ badge
4896
+ ]
4158
4897
  }
4159
4898
  );
4160
4899
  }
@@ -4206,7 +4945,10 @@ function Shell3({ title, icon, onClose, children, isDark }) {
4206
4945
  "aria-label": title,
4207
4946
  "data-testid": "geom3d-left-panel",
4208
4947
  "data-stamp-area": "true",
4209
- 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(""),
4210
4952
  children: [
4211
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: [
4212
4954
  /* @__PURE__ */ jsxRuntime.jsxs("h3", { className: "flex items-center gap-2 text-sm font-semibold text-slate-800", children: [
@@ -4219,10 +4961,7 @@ function Shell3({ title, icon, onClose, children, isDark }) {
4219
4961
  onClick: onClose,
4220
4962
  "aria-label": "\u0110\xF3ng",
4221
4963
  className: "rounded p-1 text-slate-500 transition hover:bg-slate-100 hover:text-slate-800",
4222
- 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: [
4223
- /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
4224
- /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
4225
- ] })
4964
+ children: /* @__PURE__ */ jsxRuntime.jsx(CloseIcon2, {})
4226
4965
  }
4227
4966
  )
4228
4967
  ] }),
@@ -4238,11 +4977,39 @@ function Section3({ label, children }) {
4238
4977
  ] });
4239
4978
  }
4240
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" }) });
4241
- function LeftPanel3({ handle, onResetView, onClose, isDark }) {
4242
- const [tool, setTool] = react.useState("move");
4243
- const [showAxes, setShowAxes] = react.useState(true);
4244
- const [showMesh, setShowMesh] = react.useState(false);
4245
- 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() {
4246
5013
  const [hover, setHover] = react.useState(null);
4247
5014
  const [portalReady, setPortalReady] = react.useState(false);
4248
5015
  const hoverTimerRef = react.useRef(null);
@@ -4252,17 +5019,6 @@ function LeftPanel3({ handle, onResetView, onClose, isDark }) {
4252
5019
  if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
4253
5020
  };
4254
5021
  }, []);
4255
- react.useEffect(() => {
4256
- if (!handle) return;
4257
- const sync = () => {
4258
- setTool(handle.getTool());
4259
- setShowAxes(handle.getShowAxes());
4260
- setShowMesh(handle.getShowMesh());
4261
- setCanUndo(handle.canUndo());
4262
- };
4263
- sync();
4264
- return handle.subscribe(sync);
4265
- }, [handle]);
4266
5022
  const showHover = react.useCallback((el, t) => {
4267
5023
  if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
4268
5024
  hoverTimerRef.current = setTimeout(() => {
@@ -4277,14 +5033,45 @@ function LeftPanel3({ handle, onResetView, onClose, isDark }) {
4277
5033
  }
4278
5034
  setHover(null);
4279
5035
  }, []);
4280
- const grouped = TOOLS_3D.reduce(
4281
- (acc, t) => {
4282
- var _a;
4283
- (acc[_a = t.group] ?? (acc[_a] = [])).push(t);
4284
- return acc;
4285
- },
4286
- {}
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]
4287
5073
  );
5074
+ const activeGroupTools = chordGroup ? grouped[chordGroup] ?? null : null;
4288
5075
  return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
4289
5076
  /* @__PURE__ */ jsxRuntime.jsxs(Shell3, { title: "H\xECnh h\u1ECDc 3D", icon: Geom3DIconHeader, onClose, isDark, children: [
4290
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: [
@@ -4320,10 +5107,7 @@ function LeftPanel3({ handle, onResetView, onClose, isDark }) {
4320
5107
  title: "Reset g\xF3c nh\xECn",
4321
5108
  "aria-label": "Reset view",
4322
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",
4323
- 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: [
4324
- /* @__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" }),
4325
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3 3v5h5" })
4326
- ] })
5110
+ children: /* @__PURE__ */ jsxRuntime.jsx(ResetViewIcon, {})
4327
5111
  }
4328
5112
  ),
4329
5113
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -4335,34 +5119,95 @@ function LeftPanel3({ handle, onResetView, onClose, isDark }) {
4335
5119
  title: "Ho\xE0n t\xE1c (Ctrl/Cmd+Z)",
4336
5120
  "aria-label": "Ho\xE0n t\xE1c",
4337
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",
4338
- 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: [
4339
- /* @__PURE__ */ jsxRuntime.jsx("polyline", { points: "3 7 3 13 9 13" }),
4340
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3.51 13a9 9 0 1 0 2.13-9.36L3 7" })
4341
- ] })
5122
+ children: /* @__PURE__ */ jsxRuntime.jsx(UndoIcon2, {})
4342
5123
  }
4343
5124
  )
4344
5125
  ] }) }),
4345
- 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(
4346
- 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",
4347
5197
  {
4348
- toolKey: t.key,
4349
- label: t.label,
4350
- hint: t.hint,
4351
- active: tool === t.key,
4352
- onClick: () => handle?.setTool(t.key),
4353
- icon: /* @__PURE__ */ jsxRuntime.jsx(
4354
- "span",
4355
- {
4356
- onMouseEnter: (e) => showHover(e.currentTarget.closest("button"), t),
4357
- onMouseLeave: hideHover,
4358
- onFocus: (e) => showHover(e.currentTarget.closest("button"), t),
4359
- onBlur: hideHover,
4360
- children: ICONS_3D[t.key]
4361
- }
4362
- )
4363
- },
4364
- t.key
4365
- )) }) }, 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
+ )
4366
5211
  ] }),
4367
5212
  portalReady && hover && typeof document !== "undefined" ? reactDom.createPortal(
4368
5213
  /* @__PURE__ */ jsxRuntime.jsxs(
@@ -4386,107 +5231,71 @@ function LeftPanel3({ handle, onResetView, onClose, isDark }) {
4386
5231
  ) : null
4387
5232
  ] });
4388
5233
  }
4389
- var EditorPanel = react.forwardRef(function EditorPanel2({ isDark, initial, onInsert, onClose }, ref) {
4390
- const boardRef = react.useRef(null);
4391
- const [boardHandle, setBoardHandle] = react.useState(null);
4392
- const setBoard = react.useCallback((h) => {
4393
- boardRef.current = h;
4394
- setBoardHandle((prev) => prev === h ? prev : h);
4395
- }, []);
4396
- react.useImperativeHandle(
4397
- ref,
4398
- () => ({
4399
- tryInsert: () => {
4400
- const board = boardRef.current;
4401
- if (!board) return false;
4402
- const log = board.getCreationLog();
4403
- if (log.length === 0) return false;
4404
- const view = board.getViewState();
4405
- const state = {
4406
- version: 1,
4407
- bbox: board.getBbox(),
4408
- view,
4409
- showAxes: board.getShowAxes(),
4410
- showMesh: board.getShowMesh(),
4411
- elements: log
4412
- };
4413
- const snap = board.snapshotSVG();
4414
- onInsert(JSON.stringify(state), snap.svgString, snap.width, snap.height);
4415
- return true;
4416
- },
4417
- hasContent: () => (boardRef.current?.getCreationLog().length ?? 0) > 0
4418
- }),
4419
- [onInsert]
4420
- );
4421
- const handleResetView = react.useCallback(() => {
4422
- 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
+ }));
4423
5248
  }, []);
4424
- return /* @__PURE__ */ jsxRuntime.jsxs(
4425
- "div",
5249
+ return /* @__PURE__ */ jsxRuntime.jsx(
5250
+ MobileToolDrawer,
4426
5251
  {
4427
- "data-testid": "geom3d-editor-panel",
4428
- "data-stamp-area": "true",
4429
- style: {
4430
- position: "absolute",
4431
- left: "50%",
4432
- top: "50%",
4433
- transform: "translate(-50%, -50%)",
4434
- width: 900,
4435
- height: 700,
4436
- background: "#fff",
4437
- boxShadow: "0 6px 32px rgba(0,0,0,0.2)",
4438
- borderRadius: 8,
4439
- zIndex: 10,
4440
- display: "flex",
4441
- flexDirection: "column",
4442
- overflow: "hidden"
4443
- },
4444
- children: [
4445
- /* @__PURE__ */ jsxRuntime.jsxs(
4446
- "div",
4447
- {
4448
- style: {
4449
- display: "flex",
4450
- padding: "8px 12px",
4451
- borderBottom: "1px solid #eee",
4452
- alignItems: "center"
4453
- },
4454
- children: [
4455
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: { fontWeight: 600 }, children: "H\xECnh h\u1ECDc kh\xF4ng gian (3D)" }),
4456
- /* @__PURE__ */ jsxRuntime.jsx("span", { style: { flex: 1 } }),
4457
- /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", onClick: onClose, children: "\u0110\xF3ng" })
4458
- ]
4459
- }
4460
- ),
4461
- /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { position: "relative", flex: 1, minHeight: 0 }, children: [
4462
- /* @__PURE__ */ jsxRuntime.jsx(
4463
- LeftPanel3,
4464
- {
4465
- handle: boardHandle,
4466
- onResetView: handleResetView,
4467
- onClose,
4468
- isDark
4469
- }
4470
- ),
4471
- /* @__PURE__ */ jsxRuntime.jsx(
4472
- "div",
4473
- {
4474
- style: {
4475
- position: "absolute",
4476
- left: 120,
4477
- top: 0,
4478
- right: 0,
4479
- bottom: 0,
4480
- overflow: "hidden"
4481
- },
4482
- children: /* @__PURE__ */ jsxRuntime.jsx(MiniBoard3D, { ref: setBoard, isDark, initialState: initial })
4483
- }
4484
- )
4485
- ] })
4486
- ]
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)
4487
5292
  }
4488
5293
  );
4489
- });
5294
+ }
5295
+ function LeftPanel3(props) {
5296
+ if (props.isMobile) return /* @__PURE__ */ jsxRuntime.jsx(MobilePanel, { ...props });
5297
+ return /* @__PURE__ */ jsxRuntime.jsx(DesktopPanel, { ...props });
5298
+ }
4490
5299
 
4491
5300
  // src/stamps/geometry-3d/serialize.ts
4492
5301
  function isGeometry3DCustomData(data) {
@@ -4571,66 +5380,1528 @@ async function renderGeometry3DSvgFromState(jsonState) {
4571
5380
  JXG.JSXGraph.freeBoard(board);
4572
5381
  } catch {
4573
5382
  }
4574
- return { svgString, width: OUTPUT_WIDTH, height: OUTPUT_HEIGHT };
4575
- } finally {
4576
- document.body.removeChild(div);
4577
- }
4578
- }
4579
- function parseInitial(editingElement) {
4580
- if (!editingElement) return null;
4581
- if (!isGeometry3DCustomData(editingElement.customData)) return null;
4582
- try {
4583
- return parseSerializedBoard3D(editingElement.customData.jsonState);
4584
- } catch {
4585
- return null;
4586
- }
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";
4587
6801
  }
4588
- var Geometry3DStampHost = react.forwardRef(
4589
- function Geometry3DStampHost2({ api, editingElement, onClose, isDark }, ref) {
4590
- const editorRef = react.useRef(null);
4591
- const initial = react.useMemo(
4592
- () => parseInitial(editingElement),
4593
- [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
4594
6821
  );
6822
+ const [errorsSnapshot, setErrorsSnapshot] = react.useState({});
4595
6823
  const handleInsert = react.useCallback(
4596
- async (jsonState, svgString, width, height) => {
6824
+ async (jsonState, svgString) => {
4597
6825
  if (!api) return;
4598
- await insertStampImage(api, {
4599
- svgString,
4600
- makeCustomData: () => ({
4601
- kind: "geometry3d",
4602
- version: 1,
4603
- jsonState,
4604
- svgWidth: width,
4605
- svgHeight: height
4606
- }),
4607
- editingElementId: editingElement?.id ?? null
4608
- });
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
+ }
4609
6841
  onClose();
4610
6842
  },
4611
- [api, editingElement, onClose]
6843
+ [api, editingElement?.id, onClose]
4612
6844
  );
4613
6845
  react.useImperativeHandle(
4614
6846
  ref,
4615
6847
  () => ({
4616
- tryInsert: () => editorRef.current?.tryInsert() ?? false,
4617
- hasContent: () => editorRef.current?.hasContent() ?? false
6848
+ tryInsert: () => panelRef.current?.insert() ?? false,
6849
+ hasContent: () => panelRef.current?.hasContent() ?? false
4618
6850
  }),
4619
6851
  []
4620
6852
  );
4621
- return /* @__PURE__ */ jsxRuntime.jsx(
4622
- EditorPanel,
4623
- {
4624
- ref: editorRef,
4625
- isDark,
4626
- initial,
4627
- onInsert: handleInsert,
4628
- onClose
4629
- }
4630
- );
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
+ ] });
4631
6902
  }
4632
6903
  );
4633
- var Geometry3DIcon = /* @__PURE__ */ jsxRuntime.jsxs(
6904
+ var Graph2DIcon = /* @__PURE__ */ jsxRuntime.jsxs(
4634
6905
  "svg",
4635
6906
  {
4636
6907
  width: "20",
@@ -4643,47 +6914,45 @@ var Geometry3DIcon = /* @__PURE__ */ jsxRuntime.jsxs(
4643
6914
  strokeLinejoin: "round",
4644
6915
  "aria-hidden": "true",
4645
6916
  children: [
4646
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M12 3 L20 8 L20 16 L12 21 L4 16 L4 8 Z" }),
4647
- /* @__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" })
4648
6920
  ]
4649
6921
  }
4650
6922
  );
4651
- var geometry3dStamp = {
4652
- kind: "geometry3d",
4653
- shortcutKey: "d",
4654
- toolbarLabel: "D",
4655
- toolbarTitle: "H\xECnh 3D (D)",
4656
- toolbarIcon: Geometry3DIcon,
4657
- toolbarTestId: "stamp-toolbar-geometry3d",
4658
- 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,
4659
6931
  async renderSvgFromCustomData(data) {
4660
- if (!isGeometry3DCustomData(data)) {
4661
- 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");
4662
6934
  }
4663
- const { svgString } = await renderGeometry3DSvgFromState(data.jsonState);
4664
- return svgString;
6935
+ return renderGraph2dSvgFromState(data.jsonState);
4665
6936
  },
4666
- restoreFileFromCustomData: async (element) => {
6937
+ async restoreFileFromCustomData(element) {
4667
6938
  const data = element.customData;
4668
6939
  const fileId = element.fileId;
4669
6940
  if (!data || !fileId) return null;
4670
- if (!isGeometry3DCustomData(data)) return null;
4671
- try {
4672
- const { svgString } = await renderGeometry3DSvgFromState(data.jsonState);
4673
- const dataURL = `data:image/svg+xml;base64,${typeof btoa !== "undefined" ? btoa(unescape(encodeURIComponent(svgString))) : Buffer.from(svgString).toString("base64")}`;
4674
- return { fileId, dataURL, mimeType: "image/svg+xml" };
4675
- } catch {
4676
- return null;
4677
- }
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" };
4678
6946
  },
4679
- Host: Geometry3DStampHost
6947
+ Host: Graph2DStampHost
4680
6948
  };
4681
6949
 
4682
6950
  // src/stamps/shared/registry.ts
4683
6951
  var DEFAULT_STAMPS = Object.freeze([
4684
6952
  geometryStamp,
4685
6953
  latexStamp,
4686
- geometry3dStamp
6954
+ geometry3dStamp,
6955
+ graph2dStamp
4687
6956
  ]);
4688
6957
  function findStampForCustomData(data, stamps = DEFAULT_STAMPS) {
4689
6958
  for (const s of stamps) {
@@ -4694,36 +6963,90 @@ function findStampForCustomData(data, stamps = DEFAULT_STAMPS) {
4694
6963
  function isStampElement(element, stamps = DEFAULT_STAMPS) {
4695
6964
  return findStampForCustomData(element.customData, stamps) !== null;
4696
6965
  }
4697
- 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";
4698
6968
  function ToolbarInjector({
4699
6969
  enabled,
4700
6970
  activeStampKind,
4701
6971
  onToggle,
4702
6972
  stamps = DEFAULT_STAMPS
4703
6973
  }) {
4704
- 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);
4705
6980
  react.useEffect(() => {
4706
6981
  if (!enabled) {
4707
- 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();
4708
7026
  return;
4709
7027
  }
4710
7028
  let cancelled = false;
4711
7029
  let attempts = 0;
4712
7030
  let observer = null;
4713
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
+ };
4714
7039
  const tryMount = () => {
4715
7040
  if (cancelled) return;
4716
7041
  const container = document.querySelector(".excalidraw .App-toolbar .Stack_horizontal") ?? document.querySelector(".App-toolbar .Stack_horizontal");
4717
7042
  if (!container) {
4718
- if (attempts++ < 20) {
4719
- timer = setTimeout(tryMount, 100);
4720
- }
7043
+ if (attempts++ < 20) timer = setTimeout(tryMount, 100);
4721
7044
  return;
4722
7045
  }
4723
- let wrapper = container.querySelector("#" + WRAPPER_ID);
7046
+ let wrapper = container.querySelector("#" + TOOLBAR_WRAPPER_ID);
4724
7047
  if (!wrapper) {
4725
7048
  wrapper = document.createElement("div");
4726
- wrapper.id = WRAPPER_ID;
7049
+ wrapper.id = TOOLBAR_WRAPPER_ID;
4727
7050
  wrapper.className = "Stamp-toolbar-injector";
4728
7051
  wrapper.setAttribute("data-stamp-area", "true");
4729
7052
  wrapper.style.display = "inline-flex";
@@ -4739,13 +7062,13 @@ function ToolbarInjector({
4739
7062
  container.appendChild(wrapper);
4740
7063
  }
4741
7064
  }
4742
- setMountNode(wrapper);
7065
+ apply(wrapper);
4743
7066
  };
4744
7067
  tryMount();
4745
7068
  const root = document.querySelector(".excalidraw") ?? document.body;
4746
7069
  observer = new MutationObserver(() => {
4747
7070
  if (cancelled) return;
4748
- const stillThere = document.getElementById(WRAPPER_ID);
7071
+ const stillThere = document.getElementById(TOOLBAR_WRAPPER_ID);
4749
7072
  if (!stillThere) {
4750
7073
  attempts = 0;
4751
7074
  tryMount();
@@ -4756,11 +7079,73 @@ function ToolbarInjector({
4756
7079
  cancelled = true;
4757
7080
  if (timer) clearTimeout(timer);
4758
7081
  observer?.disconnect();
4759
- document.getElementById(WRAPPER_ID)?.remove();
7082
+ document.getElementById(TOOLBAR_WRAPPER_ID)?.remove();
4760
7083
  };
4761
- }, [enabled]);
4762
- if (!enabled || !mountNode) return null;
4763
- 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(
4764
7149
  /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: stamps.map((stamp) => /* @__PURE__ */ jsxRuntime.jsx(
4765
7150
  StampToolButton,
4766
7151
  {
@@ -4773,8 +7158,42 @@ function ToolbarInjector({
4773
7158
  },
4774
7159
  stamp.kind
4775
7160
  )) }),
4776
- mountNode
4777
- );
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
+ ] });
4778
7197
  }
4779
7198
  function StampToolButton({ icon, keybind, label, active, onClick, dataTestId }) {
4780
7199
  return /* @__PURE__ */ jsxRuntime.jsxs(
@@ -4839,6 +7258,54 @@ function StampToolButton({ icon, keybind, label, active, onClick, dataTestId })
4839
7258
  }
4840
7259
  );
4841
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
+ }
4842
7309
  function isEditableTarget(t) {
4843
7310
  if (!t || !(t instanceof HTMLElement)) return false;
4844
7311
  if (t.isContentEditable) return true;
@@ -5156,7 +7623,9 @@ function Whiteboard({
5156
7623
  stamps = DEFAULT_STAMPS
5157
7624
  }) {
5158
7625
  const [api, setApi] = react.useState(null);
7626
+ const apiRef = react.useRef(null);
5159
7627
  const [isDarkTheme, setIsDarkTheme] = react.useState(false);
7628
+ const isDarkThemeRef = react.useRef(false);
5160
7629
  const knownFileIdsRef = react.useRef(/* @__PURE__ */ new Set());
5161
7630
  const lastSceneHashRef = react.useRef("");
5162
7631
  const sceneThrottleRef = react.useRef(null);
@@ -5217,7 +7686,10 @@ function Whiteboard({
5217
7686
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
5218
7687
  (elements, appState, files) => {
5219
7688
  const nextDark = appState?.theme === "dark";
5220
- setIsDarkTheme((prev) => prev === nextDark ? prev : nextDark);
7689
+ if (isDarkThemeRef.current !== nextDark) {
7690
+ isDarkThemeRef.current = nextDark;
7691
+ queueMicrotask(() => setIsDarkTheme(nextDark));
7692
+ }
5221
7693
  if (readOnly) return;
5222
7694
  latestSceneRef.current = { elements, appState };
5223
7695
  const cropId = appState?.croppingElementId;
@@ -5227,12 +7699,17 @@ function Whiteboard({
5227
7699
  const stamp = findStampForCustomData(el.customData, stamps);
5228
7700
  if (stamp) {
5229
7701
  handledCropIdRef.current = cropId;
5230
- api.updateScene({
5231
- appState: { ...appState, croppingElementId: null, selectedElementIds: {} }
5232
- });
5233
- openStamp(stamp.kind, {
5234
- id: el.id,
5235
- 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 });
5236
7713
  });
5237
7714
  return;
5238
7715
  }
@@ -5483,8 +7960,12 @@ function Whiteboard({
5483
7960
  Excalidraw2,
5484
7961
  {
5485
7962
  excalidrawAPI: (a) => {
5486
- setApi(a);
5487
- onApi?.(a);
7963
+ if (apiRef.current === a) return;
7964
+ apiRef.current = a;
7965
+ queueMicrotask(() => {
7966
+ setApi(a);
7967
+ onApi?.(a);
7968
+ });
5488
7969
  },
5489
7970
  langCode,
5490
7971
  viewModeEnabled: readOnly,
@@ -5526,8 +8007,10 @@ exports.Whiteboard = Whiteboard;
5526
8007
  exports.findStampForCustomData = findStampForCustomData;
5527
8008
  exports.geometry3dStamp = geometry3dStamp;
5528
8009
  exports.geometryStamp = geometryStamp;
8010
+ exports.graph2dStamp = graph2dStamp;
5529
8011
  exports.isGeometry3DCustomData = isGeometry3DCustomData;
5530
8012
  exports.isGeometryCustomData = isGeometryCustomData;
8013
+ exports.isGraph2DCustomData = isGraph2DCustomData;
5531
8014
  exports.isLatexCustomData = isLatexCustomData;
5532
8015
  exports.isStampElement = isStampElement;
5533
8016
  exports.latexStamp = latexStamp;