@xom11/whiteboard 0.6.2 → 0.6.5

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.mjs CHANGED
@@ -1,13 +1,12 @@
1
1
  "use client";
2
2
  import './index.css';
3
+ import { __require } from './chunk-BJTO5JO5.mjs';
3
4
  import dynamic from 'next/dynamic';
4
- import { forwardRef, useRef, useState, useEffect, useCallback, useImperativeHandle, useMemo, useId } from 'react';
5
+ import { forwardRef, useRef, useState, useEffect, useCallback, useImperativeHandle, useMemo, useId, useLayoutEffect } from 'react';
5
6
  import { createPortal } from 'react-dom';
6
7
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
7
8
  import '@excalidraw/excalidraw/index.css';
8
9
 
9
- // src/Whiteboard.tsx
10
-
11
10
  // src/serialize.ts
12
11
  function pickSyncableAppState(s) {
13
12
  return {
@@ -252,6 +251,22 @@ var GROUP_LABELS = {
252
251
  edit: "Ch\u1EC9nh s\u1EEDa",
253
252
  transform: "Ph\xE9p bi\u1EBFn h\xECnh"
254
253
  };
254
+ var GROUP_ORDER = [
255
+ "move",
256
+ "point",
257
+ "line",
258
+ "construct",
259
+ "polygon",
260
+ "circle",
261
+ "measure",
262
+ "edit",
263
+ "transform"
264
+ ];
265
+ var A_CODE = "A".charCodeAt(0);
266
+ function letterForGroup(g) {
267
+ const idx = GROUP_ORDER.indexOf(g);
268
+ return idx >= 0 ? String.fromCharCode(A_CODE + idx) : "";
269
+ }
255
270
  function objKind(obj) {
256
271
  if (!obj) return "other";
257
272
  const e = (obj.elType || obj.type || "").toString().toLowerCase();
@@ -1644,7 +1659,24 @@ var JSXGraphMiniBoard = ({ onReady, initialState, isDark }) => {
1644
1659
  });
1645
1660
  onReady({
1646
1661
  getContainer: () => containerRef.current,
1647
- getCreationLog: () => [...creationLogRef.current],
1662
+ // Sync toạ độ live của free point về log trước khi trả ra. JSXGraph
1663
+ // cho phép drag free point (args=[x,y] không có ref), việc drag chỉ
1664
+ // cập nhật obj.X()/Y() trên board chứ không đụng log → re-edit + Chèn
1665
+ // sẽ serialize toạ độ cũ → SVG không đổi → fileId trùng → user thấy
1666
+ // "k thay đổi". Line/segment/circle/polygon tham chiếu point qua id
1667
+ // nên auto-update theo.
1668
+ getCreationLog: () => creationLogRef.current.map((e) => {
1669
+ if (e.type !== "point") return { ...e };
1670
+ const args = e.args;
1671
+ if (!Array.isArray(args) || args.length !== 2) return { ...e };
1672
+ if (typeof args[0] !== "number" || typeof args[1] !== "number") return { ...e };
1673
+ const obj = objMapRef.current.get(e.id);
1674
+ if (!obj || typeof obj.X !== "function" || typeof obj.Y !== "function") return { ...e };
1675
+ const x = obj.X();
1676
+ const y = obj.Y();
1677
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return { ...e };
1678
+ return { ...e, args: [x, y] };
1679
+ }),
1648
1680
  getBbox: () => boardRef.current ? boardRef.current.getBoundingBox() : [-10, 10, 10, -10],
1649
1681
  getShowAxis: () => showAxisRef.current,
1650
1682
  getShowGrid: () => showGridRef.current,
@@ -1842,8 +1874,140 @@ var JSXGraphMiniBoard = ({ onReady, initialState, isDark }) => {
1842
1874
  }
1843
1875
  );
1844
1876
  };
1877
+ function MobileToolDrawer({
1878
+ title,
1879
+ headerIcon,
1880
+ chips,
1881
+ actions,
1882
+ groups,
1883
+ activeTool,
1884
+ onToolSelect,
1885
+ drawerOpen,
1886
+ onDrawerClose,
1887
+ isDark,
1888
+ testId
1889
+ }) {
1890
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
1891
+ drawerOpen && /* @__PURE__ */ jsx(
1892
+ "div",
1893
+ {
1894
+ className: "stamp-drawer-backdrop",
1895
+ onPointerDown: onDrawerClose,
1896
+ "aria-hidden": "true"
1897
+ }
1898
+ ),
1899
+ /* @__PURE__ */ jsxs(
1900
+ "aside",
1901
+ {
1902
+ role: "complementary",
1903
+ "aria-label": title,
1904
+ "aria-hidden": !drawerOpen ? "true" : void 0,
1905
+ "data-testid": testId,
1906
+ "data-stamp-area": "true",
1907
+ "data-mobile-drawer": "true",
1908
+ "data-geo-mobile": "true",
1909
+ "data-drawer-state": drawerOpen ? "open" : "closed",
1910
+ className: [
1911
+ isDark ? "theme--dark " : "",
1912
+ "stamp-drawer-mobile flex flex-col border-r border-slate-200 bg-white shadow-md"
1913
+ ].join(""),
1914
+ children: [
1915
+ /* @__PURE__ */ 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: [
1916
+ /* @__PURE__ */ jsxs("h3", { className: "flex items-center gap-2 text-base font-semibold text-slate-800", children: [
1917
+ /* @__PURE__ */ jsx("span", { className: "inline-flex h-7 w-7 items-center justify-center rounded-lg bg-emerald-50 text-emerald-700", children: headerIcon }),
1918
+ title
1919
+ ] }),
1920
+ /* @__PURE__ */ jsx(
1921
+ "button",
1922
+ {
1923
+ type: "button",
1924
+ onClick: onDrawerClose,
1925
+ "aria-label": "\u0110\xF3ng ng\u0103n c\xF4ng c\u1EE5",
1926
+ 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",
1927
+ children: /* @__PURE__ */ jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
1928
+ /* @__PURE__ */ jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
1929
+ /* @__PURE__ */ jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
1930
+ ] })
1931
+ }
1932
+ )
1933
+ ] }),
1934
+ /* @__PURE__ */ 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: [
1935
+ chips.map((c) => /* @__PURE__ */ jsxs(
1936
+ "button",
1937
+ {
1938
+ type: "button",
1939
+ role: "switch",
1940
+ "aria-pressed": c.pressed,
1941
+ "aria-label": c.label,
1942
+ "data-testid": c.testId,
1943
+ onClick: () => c.onToggle(!c.pressed),
1944
+ className: "geo-mobile-chip",
1945
+ children: [
1946
+ c.icon,
1947
+ c.label
1948
+ ]
1949
+ },
1950
+ c.label
1951
+ )),
1952
+ actions.length > 0 && /* @__PURE__ */ jsx("div", { className: "ml-auto flex items-center gap-1", children: actions.map((a) => /* @__PURE__ */ jsx(
1953
+ "button",
1954
+ {
1955
+ type: "button",
1956
+ onClick: a.onClick,
1957
+ disabled: a.disabled,
1958
+ "aria-label": a.label,
1959
+ title: a.title ?? a.label,
1960
+ 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",
1961
+ children: a.icon
1962
+ },
1963
+ a.label
1964
+ )) })
1965
+ ] }),
1966
+ /* @__PURE__ */ jsx(
1967
+ "div",
1968
+ {
1969
+ className: "min-h-0 flex-1 overflow-y-auto",
1970
+ style: { paddingBottom: "calc(0.75rem + env(safe-area-inset-bottom))" },
1971
+ children: groups.map((g) => /* @__PURE__ */ jsxs("section", { className: "px-3 pt-3 pb-1", children: [
1972
+ /* @__PURE__ */ jsxs("h4", { className: "mb-2 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-slate-500", children: [
1973
+ /* @__PURE__ */ jsx("span", { className: "h-1 w-1 rounded-full bg-emerald-500" }),
1974
+ g.groupLabel
1975
+ ] }),
1976
+ /* @__PURE__ */ jsx("div", { className: "grid grid-cols-3 gap-2", children: g.tools.map((t) => {
1977
+ const active = activeTool === t.key;
1978
+ return /* @__PURE__ */ jsxs(
1979
+ "button",
1980
+ {
1981
+ type: "button",
1982
+ "aria-label": t.label,
1983
+ "aria-pressed": active,
1984
+ "data-tool": t.key,
1985
+ onClick: () => {
1986
+ onToolSelect(t.key);
1987
+ onDrawerClose();
1988
+ },
1989
+ className: [
1990
+ "flex flex-col items-center justify-center gap-1.5 rounded-2xl px-2 py-3 transition active:scale-95",
1991
+ active ? "geo-mobile-tool-active" : "bg-slate-50 text-slate-700 hover:bg-slate-100"
1992
+ ].join(" "),
1993
+ children: [
1994
+ /* @__PURE__ */ jsx("span", { className: "flex h-6 w-6 items-center justify-center", children: t.icon }),
1995
+ /* @__PURE__ */ jsx("span", { className: "text-center text-[11px] font-medium leading-tight line-clamp-2", children: t.label })
1996
+ ]
1997
+ },
1998
+ t.key
1999
+ );
2000
+ }) })
2001
+ ] }, g.group))
2002
+ }
2003
+ )
2004
+ ]
2005
+ }
2006
+ )
2007
+ ] });
2008
+ }
1845
2009
  var TOOLTIP_DELAY_MS = 400;
1846
- function Shell({ title, icon, onClose, children, isDark }) {
2010
+ function Shell({ title, icon, onClose, children, isDark, closeLabel = "\u0110\xF3ng" }) {
1847
2011
  return /* @__PURE__ */ jsxs(
1848
2012
  "aside",
1849
2013
  {
@@ -1851,7 +2015,10 @@ function Shell({ title, icon, onClose, children, isDark }) {
1851
2015
  "aria-label": title,
1852
2016
  "data-testid": "stamp-left-panel",
1853
2017
  "data-stamp-area": "true",
1854
- 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`,
2018
+ className: [
2019
+ isDark ? "theme--dark " : "",
2020
+ "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"
2021
+ ].join(""),
1855
2022
  children: [
1856
2023
  /* @__PURE__ */ 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: [
1857
2024
  /* @__PURE__ */ jsxs("h3", { className: "flex items-center gap-2 text-sm font-semibold text-slate-800", children: [
@@ -1862,12 +2029,9 @@ function Shell({ title, icon, onClose, children, isDark }) {
1862
2029
  "button",
1863
2030
  {
1864
2031
  onClick: onClose,
1865
- "aria-label": "\u0110\xF3ng",
2032
+ "aria-label": closeLabel,
1866
2033
  className: "rounded p-1 text-slate-500 transition hover:bg-slate-100 hover:text-slate-800",
1867
- children: /* @__PURE__ */ jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
1868
- /* @__PURE__ */ jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
1869
- /* @__PURE__ */ jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
1870
- ] })
2034
+ children: /* @__PURE__ */ jsx(CloseIcon, {})
1871
2035
  }
1872
2036
  )
1873
2037
  ] }),
@@ -1888,24 +2052,36 @@ var GeometryIconHeader = /* @__PURE__ */ jsxs("svg", { width: "14", height: "14"
1888
2052
  /* @__PURE__ */ jsx("circle", { cx: "20", cy: "20", r: "1.5", fill: "currentColor", stroke: "none" }),
1889
2053
  /* @__PURE__ */ jsx("circle", { cx: "12", cy: "5", r: "1.5", fill: "currentColor", stroke: "none" })
1890
2054
  ] });
1891
- function LeftPanel({
1892
- activeTool,
1893
- onToolChange,
1894
- showAxis,
1895
- showGrid,
1896
- onShowAxisChange,
1897
- onShowGridChange,
1898
- onUndo,
1899
- canUndo,
1900
- onClose,
1901
- isDark
1902
- }) {
1903
- const grouped = TOOLS.reduce((acc, t) => {
1904
- var _a;
1905
- (acc[_a = t.group] ?? (acc[_a] = [])).push(t);
1906
- return acc;
1907
- }, {});
1908
- const groupKeys = Object.keys(grouped);
2055
+ function CloseIcon() {
2056
+ return /* @__PURE__ */ jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
2057
+ /* @__PURE__ */ jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
2058
+ /* @__PURE__ */ jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
2059
+ ] });
2060
+ }
2061
+ function UndoIcon() {
2062
+ return /* @__PURE__ */ jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
2063
+ /* @__PURE__ */ jsx("polyline", { points: "3 7 3 13 9 13" }),
2064
+ /* @__PURE__ */ jsx("path", { d: "M3.51 13a9 9 0 1 0 2.13-9.36L3 7" })
2065
+ ] });
2066
+ }
2067
+ function AxisIcon() {
2068
+ return /* @__PURE__ */ jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", children: [
2069
+ /* @__PURE__ */ jsx("line", { x1: "4", y1: "20", x2: "20", y2: "20" }),
2070
+ /* @__PURE__ */ jsx("line", { x1: "4", y1: "20", x2: "4", y2: "4" }),
2071
+ /* @__PURE__ */ jsx("polyline", { points: "2 6 4 4 6 6" }),
2072
+ /* @__PURE__ */ jsx("polyline", { points: "18 18 20 20 18 22" })
2073
+ ] });
2074
+ }
2075
+ function GridIcon() {
2076
+ return /* @__PURE__ */ jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round", strokeLinejoin: "round", children: [
2077
+ /* @__PURE__ */ jsx("rect", { x: "4", y: "4", width: "16", height: "16", rx: "1" }),
2078
+ /* @__PURE__ */ jsx("line", { x1: "4", y1: "10", x2: "20", y2: "10" }),
2079
+ /* @__PURE__ */ jsx("line", { x1: "4", y1: "16", x2: "20", y2: "16" }),
2080
+ /* @__PURE__ */ jsx("line", { x1: "10", y1: "4", x2: "10", y2: "20" }),
2081
+ /* @__PURE__ */ jsx("line", { x1: "16", y1: "4", x2: "16", y2: "20" })
2082
+ ] });
2083
+ }
2084
+ function useToolHoverTooltip() {
1909
2085
  const [hover, setHover] = useState(null);
1910
2086
  const [portalReady, setPortalReady] = useState(false);
1911
2087
  const hoverTimerRef = useRef(null);
@@ -1929,6 +2105,23 @@ function LeftPanel({
1929
2105
  }
1930
2106
  setHover(null);
1931
2107
  }, []);
2108
+ return { hover, portalReady, showHover, hideHover };
2109
+ }
2110
+ function DesktopGeometryPanel(props) {
2111
+ const { activeTool, onToolChange, showAxis, showGrid, onShowAxisChange, onShowGridChange, onUndo, canUndo, onClose, isDark, chordGroup } = props;
2112
+ const grouped = useMemo(() => {
2113
+ return TOOLS.reduce((acc, t) => {
2114
+ var _a;
2115
+ (acc[_a = t.group] ?? (acc[_a] = [])).push(t);
2116
+ return acc;
2117
+ }, {});
2118
+ }, []);
2119
+ const groupKeys = useMemo(
2120
+ () => GROUP_ORDER.filter((g) => grouped[g]),
2121
+ [grouped]
2122
+ );
2123
+ const activeGroupTools = chordGroup ? grouped[chordGroup] ?? null : null;
2124
+ const { hover, portalReady, showHover, hideHover } = useToolHoverTooltip();
1932
2125
  return /* @__PURE__ */ jsxs(Fragment, { children: [
1933
2126
  /* @__PURE__ */ jsxs(Shell, { title: "H\xECnh h\u1ECDc", icon: GeometryIconHeader, onClose, isDark, children: [
1934
2127
  /* @__PURE__ */ jsx(Section, { label: "B\u1ED1 c\u1EE5c", children: /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 text-[11px] text-slate-700", children: [
@@ -1965,36 +2158,95 @@ function LeftPanel({
1965
2158
  title: "Ho\xE0n t\xE1c (Ctrl/Cmd+Z)",
1966
2159
  "aria-label": "Ho\xE0n t\xE1c",
1967
2160
  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",
1968
- children: /* @__PURE__ */ jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
1969
- /* @__PURE__ */ jsx("polyline", { points: "3 7 3 13 9 13" }),
1970
- /* @__PURE__ */ jsx("path", { d: "M3.51 13a9 9 0 1 0 2.13-9.36L3 7" })
1971
- ] })
2161
+ children: /* @__PURE__ */ jsx(UndoIcon, {})
1972
2162
  }
1973
2163
  )
1974
2164
  ] }) }),
1975
- groupKeys.map((group) => /* @__PURE__ */ jsx(Section, { label: GROUP_LABELS[group], children: /* @__PURE__ */ jsx("div", { className: "grid grid-cols-4 gap-1", children: grouped[group].map((t) => {
1976
- const active = activeTool === t.key;
1977
- return /* @__PURE__ */ jsx(
1978
- "button",
2165
+ groupKeys.map((group) => {
2166
+ const isChordActive = chordGroup === group;
2167
+ const dimmed = chordGroup !== null && !isChordActive;
2168
+ return /* @__PURE__ */ jsxs(
2169
+ "section",
1979
2170
  {
1980
- type: "button",
1981
- "aria-label": t.label,
1982
- "aria-pressed": active,
1983
- "data-tool": t.key,
1984
- onClick: () => onToolChange(t.key),
1985
- onMouseEnter: (e) => showHover(e.currentTarget, t),
1986
- onMouseLeave: hideHover,
1987
- onFocus: (e) => showHover(e.currentTarget, t),
1988
- onBlur: hideHover,
2171
+ "data-chord-group": group,
2172
+ "data-chord-active": isChordActive ? "true" : "false",
1989
2173
  className: [
1990
- "flex h-8 items-center justify-center rounded-md transition",
1991
- active ? "bg-emerald-600 text-white shadow-sm" : "text-slate-700 hover:bg-slate-100 hover:text-slate-900"
2174
+ "rounded-md transition",
2175
+ isChordActive ? "bg-emerald-50 ring-1 ring-emerald-400 p-1" : "p-0",
2176
+ dimmed ? "opacity-55" : "opacity-100"
1992
2177
  ].join(" "),
1993
- children: t.icon
2178
+ children: [
2179
+ /* @__PURE__ */ jsxs("h4", { className: "mb-1.5 flex items-center justify-between text-[10px] font-semibold uppercase tracking-wider text-slate-500", children: [
2180
+ /* @__PURE__ */ jsx("span", { children: GROUP_LABELS[group] }),
2181
+ /* @__PURE__ */ jsx(
2182
+ "span",
2183
+ {
2184
+ "data-testid": `chord-letter-${group}`,
2185
+ className: [
2186
+ "font-mono text-[10px] leading-none transition",
2187
+ isChordActive ? "text-emerald-700 font-bold" : "text-slate-400"
2188
+ ].join(" "),
2189
+ children: letterForGroup(group)
2190
+ }
2191
+ )
2192
+ ] }),
2193
+ /* @__PURE__ */ jsx("div", { className: "grid grid-cols-4 gap-1", children: grouped[group].map((t, i) => {
2194
+ const active = activeTool === t.key;
2195
+ return /* @__PURE__ */ jsxs(
2196
+ "button",
2197
+ {
2198
+ type: "button",
2199
+ "aria-label": t.label,
2200
+ "aria-pressed": active,
2201
+ "data-tool": t.key,
2202
+ onClick: () => onToolChange(t.key),
2203
+ onMouseEnter: (e) => showHover(e.currentTarget, t),
2204
+ onMouseLeave: hideHover,
2205
+ onFocus: (e) => showHover(e.currentTarget, t),
2206
+ onBlur: hideHover,
2207
+ className: [
2208
+ "relative flex h-8 items-center justify-center rounded-md transition",
2209
+ active ? "bg-emerald-600 text-white shadow-sm" : "text-slate-700 hover:bg-slate-100 hover:text-slate-900"
2210
+ ].join(" "),
2211
+ children: [
2212
+ t.icon,
2213
+ /* @__PURE__ */ jsx(
2214
+ "span",
2215
+ {
2216
+ "data-testid": `chord-num-${t.key}`,
2217
+ className: [
2218
+ "pointer-events-none absolute bottom-0 right-0.5 font-mono text-[9px] leading-none transition",
2219
+ active ? "text-white/70" : isChordActive ? "text-emerald-700 font-bold" : "text-slate-400"
2220
+ ].join(" "),
2221
+ children: i + 1
2222
+ }
2223
+ )
2224
+ ]
2225
+ },
2226
+ t.key
2227
+ );
2228
+ }) })
2229
+ ]
1994
2230
  },
1995
- t.key
2231
+ group
1996
2232
  );
1997
- }) }) }, group))
2233
+ }),
2234
+ chordGroup && activeGroupTools && /* @__PURE__ */ jsxs(
2235
+ "div",
2236
+ {
2237
+ "data-testid": "chord-hint",
2238
+ className: "mt-1 rounded border border-emerald-200 bg-emerald-50/60 px-2 py-1 text-[11px] leading-snug text-slate-600",
2239
+ children: [
2240
+ /* @__PURE__ */ jsx("span", { className: "font-mono font-semibold text-emerald-700", children: letterForGroup(chordGroup) }),
2241
+ /* @__PURE__ */ jsx("span", { className: "mx-1 text-slate-400", children: "\u2192" }),
2242
+ activeGroupTools.map((t, i) => /* @__PURE__ */ jsxs("span", { className: "mr-2 inline-block", children: [
2243
+ /* @__PURE__ */ jsx("span", { className: "font-mono font-semibold text-emerald-700", children: i + 1 }),
2244
+ /* @__PURE__ */ jsx("span", { className: "ml-1", children: t.label })
2245
+ ] }, t.key)),
2246
+ /* @__PURE__ */ jsx("span", { className: "text-slate-400", children: "Esc hu\u1EF7" })
2247
+ ]
2248
+ }
2249
+ )
1998
2250
  ] }),
1999
2251
  portalReady && hover && typeof document !== "undefined" ? createPortal(
2000
2252
  /* @__PURE__ */ jsxs(
@@ -2018,6 +2270,78 @@ function LeftPanel({
2018
2270
  ) : null
2019
2271
  ] });
2020
2272
  }
2273
+ function MobileGeometryPanel(props) {
2274
+ const {
2275
+ activeTool,
2276
+ onToolChange,
2277
+ showAxis,
2278
+ showGrid,
2279
+ onShowAxisChange,
2280
+ onShowGridChange,
2281
+ onUndo,
2282
+ canUndo,
2283
+ isDark,
2284
+ drawerOpen,
2285
+ onDrawerClose
2286
+ } = props;
2287
+ const groups = useMemo(() => {
2288
+ const acc = /* @__PURE__ */ new Map();
2289
+ for (const t of TOOLS) {
2290
+ if (!acc.has(t.group)) acc.set(t.group, []);
2291
+ acc.get(t.group).push(t);
2292
+ }
2293
+ return Array.from(acc.entries()).map(([group, tools]) => ({
2294
+ group,
2295
+ groupLabel: GROUP_LABELS[group],
2296
+ tools: tools.map((t) => ({ key: t.key, label: t.label, icon: t.icon }))
2297
+ }));
2298
+ }, []);
2299
+ return /* @__PURE__ */ jsx(
2300
+ MobileToolDrawer,
2301
+ {
2302
+ title: "H\xECnh h\u1ECDc",
2303
+ headerIcon: GeometryIconHeader,
2304
+ testId: "stamp-left-panel",
2305
+ isDark,
2306
+ drawerOpen: !!drawerOpen,
2307
+ onDrawerClose: () => onDrawerClose?.(),
2308
+ chips: [
2309
+ {
2310
+ label: "Tr\u1EE5c",
2311
+ icon: /* @__PURE__ */ jsx(AxisIcon, {}),
2312
+ pressed: showAxis,
2313
+ onToggle: onShowAxisChange,
2314
+ testId: "toggle-axis"
2315
+ },
2316
+ {
2317
+ label: "L\u01B0\u1EDBi",
2318
+ icon: /* @__PURE__ */ jsx(GridIcon, {}),
2319
+ pressed: showGrid,
2320
+ onToggle: onShowGridChange,
2321
+ testId: "toggle-grid"
2322
+ }
2323
+ ],
2324
+ actions: [
2325
+ {
2326
+ label: "Ho\xE0n t\xE1c",
2327
+ title: "Ho\xE0n t\xE1c (Ctrl/Cmd+Z)",
2328
+ icon: /* @__PURE__ */ jsx(UndoIcon, {}),
2329
+ onClick: onUndo,
2330
+ disabled: !canUndo
2331
+ }
2332
+ ],
2333
+ groups,
2334
+ activeTool,
2335
+ onToolSelect: onToolChange
2336
+ }
2337
+ );
2338
+ }
2339
+ function LeftPanel(props) {
2340
+ if (props.isMobile) {
2341
+ return /* @__PURE__ */ jsx(MobileGeometryPanel, { ...props });
2342
+ }
2343
+ return /* @__PURE__ */ jsx(DesktopGeometryPanel, { ...props });
2344
+ }
2021
2345
 
2022
2346
  // src/stamps/geometry-2d/serialize.ts
2023
2347
  function serializeBoard(board, log, options = {}) {
@@ -2162,6 +2486,38 @@ var STROKE_PALETTE = [
2162
2486
  "#868e96"
2163
2487
  // gray
2164
2488
  ];
2489
+ var MOBILE_QUERY = "(max-width: 768px)";
2490
+ var NO_HOVER_QUERY = "(hover: none)";
2491
+ function readMatch(query) {
2492
+ if (typeof window === "undefined" || !window.matchMedia) return false;
2493
+ try {
2494
+ return window.matchMedia(query).matches;
2495
+ } catch {
2496
+ return false;
2497
+ }
2498
+ }
2499
+ function useIsMobile() {
2500
+ const [state, setState] = useState(() => ({
2501
+ isMobile: readMatch(MOBILE_QUERY),
2502
+ isTouchOnly: readMatch(NO_HOVER_QUERY)
2503
+ }));
2504
+ useEffect(() => {
2505
+ if (typeof window === "undefined" || !window.matchMedia) return;
2506
+ const mql = window.matchMedia(MOBILE_QUERY);
2507
+ const tql = window.matchMedia(NO_HOVER_QUERY);
2508
+ const update = () => {
2509
+ setState({ isMobile: mql.matches, isTouchOnly: tql.matches });
2510
+ };
2511
+ update();
2512
+ mql.addEventListener("change", update);
2513
+ tql.addEventListener("change", update);
2514
+ return () => {
2515
+ mql.removeEventListener("change", update);
2516
+ tql.removeEventListener("change", update);
2517
+ };
2518
+ }, []);
2519
+ return state;
2520
+ }
2165
2521
  var DASH_OPTIONS = [
2166
2522
  { value: 0, label: "N\xE9t li\u1EC1n" },
2167
2523
  { value: 2, label: "N\xE9t \u0111\u1EE9t" },
@@ -2220,6 +2576,26 @@ var PropertiesPopover = (props) => {
2220
2576
  const { anchor, onClose, onMutate, isDark, getAllNames } = props;
2221
2577
  const rootRef = useRef(null);
2222
2578
  const [section, setSection] = useState(null);
2579
+ const { isMobile } = useIsMobile();
2580
+ const [clamped, setClamped] = useState(null);
2581
+ useLayoutEffect(() => {
2582
+ if (typeof window === "undefined") return;
2583
+ const margin = 8;
2584
+ if (isMobile) {
2585
+ const rect2 = rootRef.current?.getBoundingClientRect();
2586
+ const w2 = rect2?.width ?? 280;
2587
+ const left2 = Math.max(margin, (window.innerWidth - w2) / 2);
2588
+ const top2 = window.innerHeight - (rect2?.height ?? 80) - margin - 12;
2589
+ setClamped({ left: left2, top: Math.max(margin, top2) });
2590
+ return;
2591
+ }
2592
+ const rect = rootRef.current?.getBoundingClientRect();
2593
+ const w = rect?.width ?? 280;
2594
+ const h = rect?.height ?? 80;
2595
+ const left = Math.max(margin, Math.min(anchor.x, window.innerWidth - w - margin));
2596
+ const top = Math.max(margin, Math.min(anchor.y, window.innerHeight - h - margin));
2597
+ setClamped({ left, top });
2598
+ }, [anchor.x, anchor.y, isMobile, section]);
2223
2599
  const initialName = props.kind === "point" ? props.currentName : props.kind === "line" || props.kind === "circle" ? props.currentName : "";
2224
2600
  const [name, setName] = useState(initialName);
2225
2601
  useEffect(() => {
@@ -2232,14 +2608,14 @@ var PropertiesPopover = (props) => {
2232
2608
  onClose();
2233
2609
  }
2234
2610
  };
2235
- const onMouseDown = (e) => {
2611
+ const onPointerDown = (e) => {
2236
2612
  if (!rootRef.current?.contains(e.target)) onClose();
2237
2613
  };
2238
2614
  document.addEventListener("keydown", onKey);
2239
- document.addEventListener("mousedown", onMouseDown, { capture: true });
2615
+ document.addEventListener("pointerdown", onPointerDown, { capture: true });
2240
2616
  return () => {
2241
2617
  document.removeEventListener("keydown", onKey);
2242
- document.removeEventListener("mousedown", onMouseDown, { capture: true });
2618
+ document.removeEventListener("pointerdown", onPointerDown, { capture: true });
2243
2619
  };
2244
2620
  }, [onClose]);
2245
2621
  const pickColor = (c) => {
@@ -2276,6 +2652,7 @@ var PropertiesPopover = (props) => {
2276
2652
  {
2277
2653
  type: "button",
2278
2654
  "data-section": id,
2655
+ "data-pill-btn": id,
2279
2656
  "aria-label": label,
2280
2657
  "aria-pressed": !!active,
2281
2658
  onClick,
@@ -2294,13 +2671,14 @@ var PropertiesPopover = (props) => {
2294
2671
  }
2295
2672
  );
2296
2673
  const colorIndicatorTint = useMemo(() => currentColor, [currentColor]);
2674
+ const pos = clamped ?? { left: anchor.x, top: anchor.y };
2297
2675
  const node = /* @__PURE__ */ jsxs(
2298
2676
  "div",
2299
2677
  {
2300
2678
  ref: rootRef,
2301
2679
  "data-stamp-area": "true",
2302
2680
  className: `${isDark ? "theme--dark " : ""}fixed z-[2147483600] flex flex-col gap-1.5`,
2303
- style: { left: anchor.x, top: anchor.y },
2681
+ style: { left: pos.left, top: pos.top },
2304
2682
  role: "dialog",
2305
2683
  "aria-label": "Thu\u1ED9c t\xEDnh \u0111\u1ED1i t\u01B0\u1EE3ng",
2306
2684
  children: [
@@ -2490,7 +2868,7 @@ var TransformParamPopover = ({ kind, anchor, defaultValue, onConfirm, onCancel,
2490
2868
  return createPortal(node, document.body);
2491
2869
  };
2492
2870
  var GeometryEditorPanel = forwardRef(
2493
- function GeometryEditorPanel2({ initialState, onInsert, onClose, withLeftPanel = false, onStateChange, isDark }, ref) {
2871
+ function GeometryEditorPanel2({ initialState, onInsert, onClose, withLeftPanel = false, onStateChange, isDark, isMobile = false, onOpenDrawer }, ref) {
2494
2872
  const handleRef = useRef(null);
2495
2873
  const [ready, setReady] = useState(false);
2496
2874
  const [propsPopover, setPropsPopover] = useState(null);
@@ -2552,7 +2930,7 @@ var GeometryEditorPanel = forwardRef(
2552
2930
  insert: performInsert,
2553
2931
  hasContent: () => (handleRef.current?.getCreationLog().length ?? 0) > 0
2554
2932
  }), [performInsert]);
2555
- const wrapperStyle = {
2933
+ const wrapperStyle = isMobile ? { position: "fixed", inset: 0, zIndex: 40 } : {
2556
2934
  position: "absolute",
2557
2935
  top: "50%",
2558
2936
  left: withLeftPanel ? "calc(50% + 120px)" : "50%",
@@ -2566,11 +2944,30 @@ var GeometryEditorPanel = forwardRef(
2566
2944
  "aria-label": "D\u1EF1ng h\xECnh h\u1ECDc",
2567
2945
  "data-testid": "geometry-editor-panel",
2568
2946
  "data-stamp-area": "true",
2947
+ "data-mobile-editor": isMobile ? "true" : void 0,
2569
2948
  style: wrapperStyle,
2570
- 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`,
2949
+ className: [
2950
+ isDark ? "theme--dark " : "",
2951
+ "flex flex-col overflow-hidden bg-white",
2952
+ 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"
2953
+ ].join(" "),
2571
2954
  children: [
2572
- /* @__PURE__ */ 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: [
2573
- /* @__PURE__ */ jsxs("h3", { className: "flex items-center gap-2 text-sm font-semibold", children: [
2955
+ /* @__PURE__ */ 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: [
2956
+ isMobile && /* @__PURE__ */ jsx(
2957
+ "button",
2958
+ {
2959
+ type: "button",
2960
+ onClick: onOpenDrawer,
2961
+ "aria-label": "M\u1EDF ng\u0103n c\xF4ng c\u1EE5",
2962
+ className: "-ml-1 inline-flex h-10 w-10 items-center justify-center rounded transition hover:bg-white/15",
2963
+ children: /* @__PURE__ */ jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
2964
+ /* @__PURE__ */ jsx("line", { x1: "4", y1: "6", x2: "20", y2: "6" }),
2965
+ /* @__PURE__ */ jsx("line", { x1: "4", y1: "12", x2: "20", y2: "12" }),
2966
+ /* @__PURE__ */ jsx("line", { x1: "4", y1: "18", x2: "20", y2: "18" })
2967
+ ] })
2968
+ }
2969
+ ),
2970
+ /* @__PURE__ */ jsxs("h3", { className: "flex flex-1 items-center gap-2 text-sm font-semibold", children: [
2574
2971
  /* @__PURE__ */ jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
2575
2972
  /* @__PURE__ */ jsx("polygon", { points: "3,18 12,3 21,18" }),
2576
2973
  /* @__PURE__ */ jsx("circle", { cx: "12", cy: "3", r: "1.5", fill: "currentColor" }),
@@ -2579,12 +2976,23 @@ var GeometryEditorPanel = forwardRef(
2579
2976
  ] }),
2580
2977
  "D\u1EF1ng h\xECnh h\u1ECDc"
2581
2978
  ] }),
2582
- /* @__PURE__ */ jsx("button", { onClick: onClose, "aria-label": "\u0110\xF3ng", className: "rounded p-1 transition hover:bg-white/15", children: /* @__PURE__ */ jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
2979
+ isMobile && /* @__PURE__ */ jsx(
2980
+ "button",
2981
+ {
2982
+ type: "button",
2983
+ onClick: handleInsert,
2984
+ disabled: !ready,
2985
+ "data-testid": "geometry-insert-btn-mobile",
2986
+ className: "rounded bg-white/15 px-3 py-1.5 text-xs font-semibold transition hover:bg-white/25 disabled:opacity-50",
2987
+ children: "Ch\xE8n"
2988
+ }
2989
+ ),
2990
+ /* @__PURE__ */ 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__ */ jsxs("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
2583
2991
  /* @__PURE__ */ jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
2584
2992
  /* @__PURE__ */ jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
2585
2993
  ] }) })
2586
2994
  ] }),
2587
- /* @__PURE__ */ jsx("div", { className: "min-h-0 flex-1", style: { height: "420px" }, children: /* @__PURE__ */ jsx(
2995
+ /* @__PURE__ */ jsx("div", { className: "min-h-0 flex-1", style: isMobile ? void 0 : { height: "420px" }, children: /* @__PURE__ */ jsx(
2588
2996
  JSXGraphMiniBoard,
2589
2997
  {
2590
2998
  onReady: handleReady,
@@ -2657,7 +3065,7 @@ var GeometryEditorPanel = forwardRef(
2657
3065
  }
2658
3066
  }
2659
3067
  ),
2660
- /* @__PURE__ */ jsxs("footer", { className: "flex items-center justify-between border-t border-slate-200 bg-slate-50 px-3 py-2", children: [
3068
+ !isMobile && /* @__PURE__ */ jsxs("footer", { className: "flex items-center justify-between border-t border-slate-200 bg-slate-50 px-3 py-2", children: [
2661
3069
  /* @__PURE__ */ 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." }),
2662
3070
  /* @__PURE__ */ jsxs("div", { className: "flex gap-2", children: [
2663
3071
  /* @__PURE__ */ jsx(
@@ -2685,6 +3093,76 @@ var GeometryEditorPanel = forwardRef(
2685
3093
  );
2686
3094
  }
2687
3095
  );
3096
+ var A_CODE2 = "a".charCodeAt(0);
3097
+ function isFieldFocused() {
3098
+ const ae = typeof document !== "undefined" ? document.activeElement : null;
3099
+ return !!(ae && (ae.tagName === "INPUT" || ae.tagName === "TEXTAREA" || ae.isContentEditable));
3100
+ }
3101
+ function useChordShortcut(args) {
3102
+ const { groupOrder, tools, onSelect, enabled } = args;
3103
+ const [chordGroup, setChordGroup] = useState(null);
3104
+ const groupOrderRef = useRef(groupOrder);
3105
+ const toolsRef = useRef(tools);
3106
+ const onSelectRef = useRef(onSelect);
3107
+ const chordGroupRef = useRef(null);
3108
+ groupOrderRef.current = groupOrder;
3109
+ toolsRef.current = tools;
3110
+ onSelectRef.current = onSelect;
3111
+ const cancel = useCallback(() => {
3112
+ chordGroupRef.current = null;
3113
+ setChordGroup(null);
3114
+ }, []);
3115
+ useEffect(() => {
3116
+ if (!enabled) return;
3117
+ const setChord = (next) => {
3118
+ chordGroupRef.current = next;
3119
+ setChordGroup(next);
3120
+ };
3121
+ const onKey = (e) => {
3122
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
3123
+ if (isFieldFocused()) return;
3124
+ const key = e.key;
3125
+ const lower = key.length === 1 ? key.toLowerCase() : key;
3126
+ if (key === "Escape") {
3127
+ if (chordGroupRef.current !== null) {
3128
+ e.preventDefault();
3129
+ e.stopPropagation();
3130
+ setChord(null);
3131
+ }
3132
+ return;
3133
+ }
3134
+ if (lower.length === 1 && lower >= "a" && lower <= "z") {
3135
+ const idx = lower.charCodeAt(0) - A_CODE2;
3136
+ if (idx < groupOrderRef.current.length) {
3137
+ e.preventDefault();
3138
+ e.stopPropagation();
3139
+ setChord(groupOrderRef.current[idx]);
3140
+ }
3141
+ return;
3142
+ }
3143
+ if (key >= "1" && key <= "9") {
3144
+ const active = chordGroupRef.current;
3145
+ if (active === null) return;
3146
+ const n = key.charCodeAt(0) - "1".charCodeAt(0);
3147
+ const toolsInGroup = toolsRef.current.filter(
3148
+ (t) => t.group === active
3149
+ );
3150
+ e.preventDefault();
3151
+ e.stopPropagation();
3152
+ if (n < toolsInGroup.length) {
3153
+ onSelectRef.current(toolsInGroup[n].key);
3154
+ }
3155
+ setChord(null);
3156
+ return;
3157
+ }
3158
+ };
3159
+ window.addEventListener("keydown", onKey, { capture: true });
3160
+ return () => {
3161
+ window.removeEventListener("keydown", onKey, { capture: true });
3162
+ };
3163
+ }, [enabled]);
3164
+ return { chordGroup, cancel };
3165
+ }
2688
3166
 
2689
3167
  // src/stamps/shared/svgToImage.ts
2690
3168
  async function hashString(input) {
@@ -2807,6 +3285,14 @@ var GeometryStampHost = forwardRef(
2807
3285
  function GeometryStampHost2({ api, editingElement, onClose, isDark }, ref) {
2808
3286
  const panelRef = useRef(null);
2809
3287
  const [geomState, setGeomState] = useState(INITIAL_GEOM_STATE);
3288
+ const { isMobile } = useIsMobile();
3289
+ const [drawerOpen, setDrawerOpen] = useState(false);
3290
+ const { chordGroup } = useChordShortcut({
3291
+ groupOrder: GROUP_ORDER,
3292
+ tools: TOOLS,
3293
+ onSelect: (key) => panelRef.current?.setTool(key),
3294
+ enabled: !isMobile
3295
+ });
2810
3296
  const initialState = useMemo(() => {
2811
3297
  if (!editingElement) return null;
2812
3298
  if (!isGeometryCustomData(editingElement.customData)) return null;
@@ -2860,7 +3346,11 @@ var GeometryStampHost = forwardRef(
2860
3346
  onUndo: () => panelRef.current?.undo(),
2861
3347
  canUndo: geomState.canUndo,
2862
3348
  onClose,
2863
- isDark
3349
+ isDark,
3350
+ isMobile,
3351
+ drawerOpen,
3352
+ onDrawerClose: () => setDrawerOpen(false),
3353
+ chordGroup
2864
3354
  }
2865
3355
  ),
2866
3356
  /* @__PURE__ */ jsx(
@@ -2871,8 +3361,10 @@ var GeometryStampHost = forwardRef(
2871
3361
  onInsert: handleInsert,
2872
3362
  onClose,
2873
3363
  onStateChange: setGeomState,
2874
- withLeftPanel: true,
2875
- isDark
3364
+ withLeftPanel: !isMobile,
3365
+ isDark,
3366
+ isMobile,
3367
+ onOpenDrawer: () => setDrawerOpen(true)
2876
3368
  }
2877
3369
  )
2878
3370
  ] });
@@ -2910,38 +3402,58 @@ var geometryStamp = {
2910
3402
  },
2911
3403
  Host: GeometryStampHost
2912
3404
  };
2913
- function Shell2({ title, icon, onClose, children }) {
2914
- return /* @__PURE__ */ jsxs(
2915
- "aside",
2916
- {
2917
- role: "complementary",
2918
- "aria-label": title,
2919
- "data-testid": "stamp-left-panel",
2920
- "data-stamp-area": "true",
2921
- 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",
2922
- children: [
2923
- /* @__PURE__ */ 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: [
2924
- /* @__PURE__ */ jsxs("h3", { className: "flex items-center gap-2 text-sm font-semibold text-slate-800", children: [
2925
- /* @__PURE__ */ jsx("span", { className: "text-base leading-none", children: icon }),
2926
- title
3405
+ function Shell2({ title, icon, onClose, children, isMobile, drawerOpen, onDrawerClose }) {
3406
+ const mobileAttrs = isMobile ? {
3407
+ "data-mobile-drawer": "true",
3408
+ "data-drawer-state": drawerOpen ? "open" : "closed"
3409
+ } : {};
3410
+ const handleHeaderClose = () => {
3411
+ if (isMobile) onDrawerClose?.();
3412
+ else onClose();
3413
+ };
3414
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
3415
+ isMobile && drawerOpen && /* @__PURE__ */ jsx(
3416
+ "div",
3417
+ {
3418
+ className: "stamp-drawer-backdrop",
3419
+ onPointerDown: onDrawerClose,
3420
+ "aria-hidden": "true"
3421
+ }
3422
+ ),
3423
+ /* @__PURE__ */ jsxs(
3424
+ "aside",
3425
+ {
3426
+ role: "complementary",
3427
+ "aria-label": title,
3428
+ "aria-hidden": isMobile && !drawerOpen ? "true" : void 0,
3429
+ "data-testid": "stamp-left-panel",
3430
+ "data-stamp-area": "true",
3431
+ ...mobileAttrs,
3432
+ 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",
3433
+ children: [
3434
+ /* @__PURE__ */ 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: [
3435
+ /* @__PURE__ */ jsxs("h3", { className: "flex items-center gap-2 text-sm font-semibold text-slate-800", children: [
3436
+ /* @__PURE__ */ jsx("span", { className: "text-base leading-none", children: icon }),
3437
+ title
3438
+ ] }),
3439
+ /* @__PURE__ */ jsx(
3440
+ "button",
3441
+ {
3442
+ onClick: handleHeaderClose,
3443
+ "aria-label": isMobile ? "\u0110\xF3ng ng\u0103n c\xF4ng c\u1EE5" : "\u0110\xF3ng",
3444
+ className: "rounded p-1 text-slate-500 transition hover:bg-slate-100 hover:text-slate-800",
3445
+ children: /* @__PURE__ */ jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
3446
+ /* @__PURE__ */ jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
3447
+ /* @__PURE__ */ jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
3448
+ ] })
3449
+ }
3450
+ )
2927
3451
  ] }),
2928
- /* @__PURE__ */ jsx(
2929
- "button",
2930
- {
2931
- onClick: onClose,
2932
- "aria-label": "\u0110\xF3ng",
2933
- className: "rounded p-1 text-slate-500 transition hover:bg-slate-100 hover:text-slate-800",
2934
- children: /* @__PURE__ */ jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
2935
- /* @__PURE__ */ jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
2936
- /* @__PURE__ */ jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
2937
- ] })
2938
- }
2939
- )
2940
- ] }),
2941
- /* @__PURE__ */ jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-3 space-y-4", children })
2942
- ]
2943
- }
2944
- );
3452
+ /* @__PURE__ */ jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-3 space-y-4", children })
3453
+ ]
3454
+ }
3455
+ )
3456
+ ] });
2945
3457
  }
2946
3458
  function Section2({ label, children }) {
2947
3459
  return /* @__PURE__ */ jsxs("section", { children: [
@@ -2988,65 +3500,80 @@ function LeftPanel2({
2988
3500
  displayMode,
2989
3501
  onDisplayModeChange,
2990
3502
  onInsertSnippet,
2991
- onClose
3503
+ onClose,
3504
+ isMobile,
3505
+ drawerOpen,
3506
+ onDrawerClose
2992
3507
  }) {
2993
- return /* @__PURE__ */ jsxs(Shell2, { title: "C\xF4ng th\u1EE9c LaTeX", icon: "\u2211", onClose, children: [
2994
- /* @__PURE__ */ jsx(Section2, { label: "Ch\u1EBF \u0111\u1ED9 hi\u1EC3n th\u1ECB", children: /* @__PURE__ */ jsxs("div", { className: "grid grid-cols-2 gap-1.5", children: [
2995
- /* @__PURE__ */ jsxs(
2996
- "button",
2997
- {
2998
- type: "button",
2999
- onClick: () => onDisplayModeChange(false),
3000
- "aria-pressed": !displayMode,
3001
- className: [
3002
- "rounded-md border px-2 py-1.5 text-xs transition",
3003
- !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"
3004
- ].join(" "),
3005
- children: [
3006
- /* @__PURE__ */ jsx("span", { className: "block font-medium", children: "Inline" }),
3007
- /* @__PURE__ */ jsx("span", { className: "block text-[10px] text-slate-500", children: "$ ... $" })
3008
- ]
3009
- }
3010
- ),
3011
- /* @__PURE__ */ jsxs(
3012
- "button",
3013
- {
3014
- type: "button",
3015
- onClick: () => onDisplayModeChange(true),
3016
- "aria-pressed": displayMode,
3017
- className: [
3018
- "rounded-md border px-2 py-1.5 text-xs transition",
3019
- 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"
3020
- ].join(" "),
3021
- children: [
3022
- /* @__PURE__ */ jsx("span", { className: "block font-medium", children: "Block" }),
3023
- /* @__PURE__ */ jsx("span", { className: "block text-[10px] text-slate-500", children: "$$ ... $$" })
3024
- ]
3025
- }
3026
- )
3027
- ] }) }),
3028
- SNIPPETS.map((group) => /* @__PURE__ */ jsx(Section2, { label: group.group, children: /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-1", children: group.items.map((s) => /* @__PURE__ */ jsx(
3029
- "button",
3030
- {
3031
- type: "button",
3032
- onClick: () => onInsertSnippet(s.snippet),
3033
- title: s.snippet,
3034
- 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",
3035
- children: s.preview
3036
- },
3037
- s.snippet
3038
- )) }) }, group.group)),
3039
- /* @__PURE__ */ jsx(Section2, { label: "Ph\xEDm t\u1EAFt", children: /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap gap-2 text-[11px] text-slate-600", children: [
3040
- /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1", children: [
3041
- /* @__PURE__ */ jsx("kbd", { className: "rounded border border-slate-300 bg-slate-50 px-1.5 py-0.5 font-mono", children: "Enter" }),
3042
- "ch\xE8n"
3043
- ] }),
3044
- /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1", children: [
3045
- /* @__PURE__ */ jsx("kbd", { className: "rounded border border-slate-300 bg-slate-50 px-1.5 py-0.5 font-mono", children: "Esc" }),
3046
- "\u0111\xF3ng"
3047
- ] })
3048
- ] }) })
3049
- ] });
3508
+ return /* @__PURE__ */ jsxs(
3509
+ Shell2,
3510
+ {
3511
+ title: "C\xF4ng th\u1EE9c LaTeX",
3512
+ icon: "\u2211",
3513
+ onClose,
3514
+ isMobile,
3515
+ drawerOpen,
3516
+ onDrawerClose,
3517
+ children: [
3518
+ /* @__PURE__ */ jsx(Section2, { label: "Ch\u1EBF \u0111\u1ED9 hi\u1EC3n th\u1ECB", children: /* @__PURE__ */ jsxs("div", { className: "grid grid-cols-2 gap-1.5", children: [
3519
+ /* @__PURE__ */ jsxs(
3520
+ "button",
3521
+ {
3522
+ type: "button",
3523
+ onClick: () => onDisplayModeChange(false),
3524
+ "aria-pressed": !displayMode,
3525
+ className: [
3526
+ "rounded-md border px-2 py-1.5 text-xs transition",
3527
+ !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"
3528
+ ].join(" "),
3529
+ children: [
3530
+ /* @__PURE__ */ jsx("span", { className: "block font-medium", children: "Inline" }),
3531
+ /* @__PURE__ */ jsx("span", { className: "block text-[10px] text-slate-500", children: "$ ... $" })
3532
+ ]
3533
+ }
3534
+ ),
3535
+ /* @__PURE__ */ jsxs(
3536
+ "button",
3537
+ {
3538
+ type: "button",
3539
+ onClick: () => onDisplayModeChange(true),
3540
+ "aria-pressed": displayMode,
3541
+ className: [
3542
+ "rounded-md border px-2 py-1.5 text-xs transition",
3543
+ 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"
3544
+ ].join(" "),
3545
+ children: [
3546
+ /* @__PURE__ */ jsx("span", { className: "block font-medium", children: "Block" }),
3547
+ /* @__PURE__ */ jsx("span", { className: "block text-[10px] text-slate-500", children: "$$ ... $$" })
3548
+ ]
3549
+ }
3550
+ )
3551
+ ] }) }),
3552
+ SNIPPETS.map((group) => /* @__PURE__ */ jsx(Section2, { label: group.group, children: /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-1", children: group.items.map((s) => /* @__PURE__ */ jsx(
3553
+ "button",
3554
+ {
3555
+ type: "button",
3556
+ "data-snippet": s.snippet,
3557
+ onClick: () => onInsertSnippet(s.snippet),
3558
+ title: s.snippet,
3559
+ 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",
3560
+ children: s.preview
3561
+ },
3562
+ s.snippet
3563
+ )) }) }, group.group)),
3564
+ /* @__PURE__ */ jsx(Section2, { label: "Ph\xEDm t\u1EAFt", children: /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap gap-2 text-[11px] text-slate-600", children: [
3565
+ /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1", children: [
3566
+ /* @__PURE__ */ jsx("kbd", { className: "rounded border border-slate-300 bg-slate-50 px-1.5 py-0.5 font-mono", children: "Enter" }),
3567
+ "ch\xE8n"
3568
+ ] }),
3569
+ /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1", children: [
3570
+ /* @__PURE__ */ jsx("kbd", { className: "rounded border border-slate-300 bg-slate-50 px-1.5 py-0.5 font-mono", children: "Esc" }),
3571
+ "\u0111\xF3ng"
3572
+ ] })
3573
+ ] }) })
3574
+ ]
3575
+ }
3576
+ );
3050
3577
  }
3051
3578
 
3052
3579
  // src/stamps/latex/render.ts
@@ -3098,7 +3625,9 @@ var EditorPopover = forwardRef(function EditorPopover2({
3098
3625
  onClose,
3099
3626
  displayMode: controlledDisplayMode,
3100
3627
  onDisplayModeChange,
3101
- withLeftPanel = false
3628
+ withLeftPanel = false,
3629
+ isMobile = false,
3630
+ onOpenDrawer
3102
3631
  }, ref) {
3103
3632
  const [value, setValue] = useState(initialValue);
3104
3633
  const [internalDisplayMode] = useState(false);
@@ -3169,7 +3698,7 @@ var EditorPopover = forwardRef(function EditorPopover2({
3169
3698
  [value, previewSvg, error, displayMode, onInsert]
3170
3699
  );
3171
3700
  const isLegacyPosition = x > 0 || y > 0;
3172
- const wrapperStyle = isLegacyPosition ? { position: "absolute", top: y, left: x, zIndex: 50 } : {
3701
+ const wrapperStyle = isMobile ? { position: "fixed", inset: 0, zIndex: 50 } : isLegacyPosition ? { position: "absolute", top: y, left: x, zIndex: 50 } : {
3173
3702
  position: "absolute",
3174
3703
  top: "50%",
3175
3704
  left: withLeftPanel ? "calc(50% + 120px)" : "50%",
@@ -3181,29 +3710,55 @@ var EditorPopover = forwardRef(function EditorPopover2({
3181
3710
  {
3182
3711
  style: wrapperStyle,
3183
3712
  "data-stamp-area": "true",
3184
- className: "w-[420px] max-w-[calc(100vw-280px)] rounded-lg border border-slate-300 bg-white shadow-2xl ring-1 ring-black/5",
3713
+ "data-mobile-editor": isMobile ? "true" : void 0,
3714
+ 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",
3185
3715
  role: "dialog",
3186
3716
  "aria-label": "Nh\u1EADp c\xF4ng th\u1EE9c LaTeX",
3187
3717
  children: [
3188
- /* @__PURE__ */ 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: [
3189
- /* @__PURE__ */ jsxs("h3", { className: "flex items-center gap-2 text-sm font-semibold", children: [
3718
+ /* @__PURE__ */ 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: [
3719
+ isMobile && /* @__PURE__ */ jsx(
3720
+ "button",
3721
+ {
3722
+ type: "button",
3723
+ onClick: onOpenDrawer,
3724
+ "aria-label": "M\u1EDF ng\u0103n snippet",
3725
+ className: "-ml-1 inline-flex h-10 w-10 items-center justify-center rounded transition hover:bg-white/15",
3726
+ children: /* @__PURE__ */ jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
3727
+ /* @__PURE__ */ jsx("line", { x1: "4", y1: "6", x2: "20", y2: "6" }),
3728
+ /* @__PURE__ */ jsx("line", { x1: "4", y1: "12", x2: "20", y2: "12" }),
3729
+ /* @__PURE__ */ jsx("line", { x1: "4", y1: "18", x2: "20", y2: "18" })
3730
+ ] })
3731
+ }
3732
+ ),
3733
+ /* @__PURE__ */ jsxs("h3", { className: "flex flex-1 items-center gap-2 text-sm font-semibold", children: [
3190
3734
  /* @__PURE__ */ jsx("span", { className: "text-base leading-none", children: "\u2211" }),
3191
3735
  "C\xF4ng th\u1EE9c LaTeX"
3192
3736
  ] }),
3737
+ isMobile && /* @__PURE__ */ jsx(
3738
+ "button",
3739
+ {
3740
+ type: "button",
3741
+ onClick: handleInsert,
3742
+ disabled: !previewSvg || !!error,
3743
+ "data-testid": "latex-insert-btn-mobile",
3744
+ className: "rounded bg-white/15 px-3 py-1.5 text-xs font-semibold transition hover:bg-white/25 disabled:opacity-50",
3745
+ children: "Ch\xE8n"
3746
+ }
3747
+ ),
3193
3748
  /* @__PURE__ */ jsx(
3194
3749
  "button",
3195
3750
  {
3196
3751
  onClick: onClose,
3197
3752
  "aria-label": "\u0110\xF3ng",
3198
- className: "rounded p-1 transition hover:bg-white/15",
3199
- children: /* @__PURE__ */ jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
3753
+ className: "inline-flex h-9 w-9 items-center justify-center rounded transition hover:bg-white/15",
3754
+ children: /* @__PURE__ */ jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
3200
3755
  /* @__PURE__ */ jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
3201
3756
  /* @__PURE__ */ jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
3202
3757
  ] })
3203
3758
  }
3204
3759
  )
3205
3760
  ] }),
3206
- /* @__PURE__ */ jsxs("div", { className: "space-y-2 p-3", children: [
3761
+ /* @__PURE__ */ jsxs("div", { className: `space-y-2 p-3${isMobile ? " flex min-h-0 flex-1 flex-col" : ""}`, children: [
3207
3762
  /* @__PURE__ */ jsx(
3208
3763
  "input",
3209
3764
  {
@@ -3214,7 +3769,7 @@ var EditorPopover = forwardRef(function EditorPopover2({
3214
3769
  onChange: (e) => setValue(e.target.value),
3215
3770
  onKeyDown: handleKeyDown,
3216
3771
  placeholder: "Vd: \\frac{a^2+b^2}{c}",
3217
- 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",
3772
+ 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"}`,
3218
3773
  autoFocus: true
3219
3774
  }
3220
3775
  ),
@@ -3222,7 +3777,8 @@ var EditorPopover = forwardRef(function EditorPopover2({
3222
3777
  "div",
3223
3778
  {
3224
3779
  className: [
3225
- "flex min-h-[64px] items-center justify-center rounded border p-3 text-center",
3780
+ "flex items-center justify-center rounded border p-3 text-center",
3781
+ isMobile ? "min-h-0 flex-1 overflow-auto" : "min-h-[64px]",
3226
3782
  error ? "border-rose-300 bg-rose-50 text-rose-700" : "border-slate-200 bg-slate-50"
3227
3783
  ].join(" "),
3228
3784
  children: error ? /* @__PURE__ */ jsxs("span", { className: "text-xs", children: [
@@ -3231,7 +3787,7 @@ var EditorPopover = forwardRef(function EditorPopover2({
3231
3787
  ] }) : previewSvg ? /* @__PURE__ */ jsx("span", { dangerouslySetInnerHTML: { __html: previewSvg } }) : /* @__PURE__ */ jsx("span", { className: "text-xs text-slate-400", children: "(xem tr\u01B0\u1EDBc)" })
3232
3788
  }
3233
3789
  ),
3234
- /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between", children: [
3790
+ !isMobile && /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between", children: [
3235
3791
  /* @__PURE__ */ jsxs("span", { className: "text-[11px] text-slate-500", children: [
3236
3792
  displayMode ? "Block" : "Inline",
3237
3793
  " \xB7 Enter \u0111\u1EC3 ch\xE8n"
@@ -3255,6 +3811,10 @@ var EditorPopover = forwardRef(function EditorPopover2({
3255
3811
  }
3256
3812
  )
3257
3813
  ] })
3814
+ ] }),
3815
+ isMobile && /* @__PURE__ */ jsxs("div", { className: "text-center text-[11px] text-slate-500", children: [
3816
+ displayMode ? "Block" : "Inline",
3817
+ " \xB7 B\u1EA5m Ch\xE8n \u1EDF thanh tr\xEAn"
3258
3818
  ] })
3259
3819
  ] })
3260
3820
  ]
@@ -3269,6 +3829,8 @@ function isLatexCustomData(data) {
3269
3829
  var LatexStampHost = forwardRef(
3270
3830
  function LatexStampHost2({ api, editingElement, onClose }, ref) {
3271
3831
  const editorRef = useRef(null);
3832
+ const { isMobile } = useIsMobile();
3833
+ const [drawerOpen, setDrawerOpen] = useState(false);
3272
3834
  const initial = useMemo(() => {
3273
3835
  if (editingElement && isLatexCustomData(editingElement.customData)) {
3274
3836
  return {
@@ -3315,7 +3877,10 @@ var LatexStampHost = forwardRef(
3315
3877
  displayMode,
3316
3878
  onDisplayModeChange: setDisplayMode,
3317
3879
  onInsertSnippet: (s) => editorRef.current?.insertAtCursor(s),
3318
- onClose
3880
+ onClose,
3881
+ isMobile,
3882
+ drawerOpen,
3883
+ onDrawerClose: () => setDrawerOpen(false)
3319
3884
  }
3320
3885
  ),
3321
3886
  /* @__PURE__ */ jsx(
@@ -3329,7 +3894,9 @@ var LatexStampHost = forwardRef(
3329
3894
  onDisplayModeChange: setDisplayMode,
3330
3895
  onInsert: handleInsert,
3331
3896
  onClose,
3332
- withLeftPanel: true
3897
+ withLeftPanel: !isMobile,
3898
+ isMobile,
3899
+ onOpenDrawer: () => setDrawerOpen(true)
3333
3900
  }
3334
3901
  )
3335
3902
  ] });
@@ -3961,7 +4528,25 @@ var MiniBoard3D = forwardRef(function MiniBoard3D2({ isDark, initialState }, ref
3961
4528
  toolRef.current = t;
3962
4529
  notify();
3963
4530
  },
3964
- getCreationLog: () => [...logRef.current],
4531
+ // Sync toạ độ live của free point3d về log trước khi trả ra. JSXGraph
4532
+ // cho phép drag point3d (parents=[x,y,z] không có ref), việc drag chỉ
4533
+ // cập nhật obj.X()/Y()/Z() chứ không đụng log → re-edit + Chèn sẽ
4534
+ // serialize toạ độ cũ → SVG không đổi → fileId trùng → user thấy
4535
+ // "k thay đổi". Line/plane/polygon/sphere tham chiếu point qua @id nên
4536
+ // auto-update theo.
4537
+ getCreationLog: () => logRef.current.map((e) => {
4538
+ if (e.type !== "point3d") return { ...e };
4539
+ const parents = e.parents;
4540
+ if (!Array.isArray(parents) || parents.length !== 3) return { ...e };
4541
+ if (typeof parents[0] !== "number" || typeof parents[1] !== "number" || typeof parents[2] !== "number") return { ...e };
4542
+ const obj = objMapRef.current.get(e.id);
4543
+ if (!obj || typeof obj.X !== "function" || typeof obj.Y !== "function" || typeof obj.Z !== "function") return { ...e };
4544
+ const x = obj.X();
4545
+ const y = obj.Y();
4546
+ const z = obj.Z();
4547
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return { ...e };
4548
+ return { ...e, parents: [x, y, z] };
4549
+ }),
3965
4550
  pushLog: (e) => {
3966
4551
  logRef.current.push(e);
3967
4552
  notify();
@@ -4037,15 +4622,159 @@ var MiniBoard3D = forwardRef(function MiniBoard3D2({ isDark, initialState }, ref
4037
4622
  }
4038
4623
  );
4039
4624
  });
4040
-
4041
- // src/stamps/geometry-3d/editor/tools.ts
4042
- var GROUP_LABELS_3D = {
4043
- view: "Xem",
4044
- primitive: "C\u01A1 b\u1EA3n",
4045
- solid: "Kh\u1ED1i \u0111a di\u1EC7n",
4046
- curved: "Kh\u1ED1i cong",
4047
- meta: "Kh\xE1c"
4048
- };
4625
+ var EditorPanel = forwardRef(function EditorPanel2({ isDark, initial, onInsert, onClose, isMobile = false, withLeftPanel = false, onBoardReady, onOpenDrawer }, ref) {
4626
+ const boardRef = useRef(null);
4627
+ const [ready, setReady] = useState(false);
4628
+ const onBoardReadyRef = useRef(onBoardReady);
4629
+ onBoardReadyRef.current = onBoardReady;
4630
+ const setBoard = useCallback((h) => {
4631
+ boardRef.current = h;
4632
+ setReady(!!h);
4633
+ onBoardReadyRef.current?.(h);
4634
+ }, []);
4635
+ const performInsert = useCallback(() => {
4636
+ const board = boardRef.current;
4637
+ if (!board) return false;
4638
+ const log = board.getCreationLog();
4639
+ if (log.length === 0) return false;
4640
+ const view = board.getViewState();
4641
+ const state = {
4642
+ version: 1,
4643
+ bbox: board.getBbox(),
4644
+ view,
4645
+ showAxes: board.getShowAxes(),
4646
+ showMesh: board.getShowMesh(),
4647
+ elements: log
4648
+ };
4649
+ const snap = board.snapshotSVG();
4650
+ onInsert(JSON.stringify(state), snap.svgString, snap.width, snap.height);
4651
+ return true;
4652
+ }, [onInsert]);
4653
+ useImperativeHandle(
4654
+ ref,
4655
+ () => ({
4656
+ tryInsert: performInsert,
4657
+ hasContent: () => (boardRef.current?.getCreationLog().length ?? 0) > 0
4658
+ }),
4659
+ [performInsert]
4660
+ );
4661
+ const handleInsert = useCallback(() => {
4662
+ performInsert();
4663
+ }, [performInsert]);
4664
+ const wrapperStyle = isMobile ? { position: "fixed", inset: 0, zIndex: 40 } : {
4665
+ position: "absolute",
4666
+ top: "50%",
4667
+ left: withLeftPanel ? "calc(50% + 120px)" : "50%",
4668
+ transform: "translate(-50%, -50%)",
4669
+ zIndex: 40
4670
+ };
4671
+ return /* @__PURE__ */ jsxs(
4672
+ "div",
4673
+ {
4674
+ role: "dialog",
4675
+ "aria-label": "D\u1EF1ng h\xECnh h\u1ECDc 3D",
4676
+ "data-testid": "geom3d-editor-panel",
4677
+ "data-stamp-area": "true",
4678
+ "data-mobile-editor": isMobile ? "true" : void 0,
4679
+ style: wrapperStyle,
4680
+ className: [
4681
+ isDark ? "theme--dark " : "",
4682
+ "flex flex-col overflow-hidden bg-white",
4683
+ 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"
4684
+ ].join(" "),
4685
+ children: [
4686
+ /* @__PURE__ */ 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: [
4687
+ isMobile && /* @__PURE__ */ jsx(
4688
+ "button",
4689
+ {
4690
+ type: "button",
4691
+ onClick: onOpenDrawer,
4692
+ "aria-label": "M\u1EDF ng\u0103n c\xF4ng c\u1EE5",
4693
+ className: "-ml-1 inline-flex h-10 w-10 items-center justify-center rounded transition hover:bg-white/15",
4694
+ children: /* @__PURE__ */ jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
4695
+ /* @__PURE__ */ jsx("line", { x1: "4", y1: "6", x2: "20", y2: "6" }),
4696
+ /* @__PURE__ */ jsx("line", { x1: "4", y1: "12", x2: "20", y2: "12" }),
4697
+ /* @__PURE__ */ jsx("line", { x1: "4", y1: "18", x2: "20", y2: "18" })
4698
+ ] })
4699
+ }
4700
+ ),
4701
+ /* @__PURE__ */ jsxs("h3", { className: "flex flex-1 items-center gap-2 text-sm font-semibold", children: [
4702
+ /* @__PURE__ */ jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: /* @__PURE__ */ 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" }) }),
4703
+ "H\xECnh h\u1ECDc kh\xF4ng gian (3D)"
4704
+ ] }),
4705
+ isMobile && /* @__PURE__ */ jsx(
4706
+ "button",
4707
+ {
4708
+ type: "button",
4709
+ onClick: handleInsert,
4710
+ disabled: !ready,
4711
+ "data-testid": "geom3d-insert-btn-mobile",
4712
+ className: "rounded bg-white/15 px-3 py-1.5 text-xs font-semibold transition hover:bg-white/25 disabled:opacity-50",
4713
+ children: "Ch\xE8n"
4714
+ }
4715
+ ),
4716
+ /* @__PURE__ */ jsx(
4717
+ "button",
4718
+ {
4719
+ onClick: onClose,
4720
+ "aria-label": "\u0110\xF3ng",
4721
+ className: "inline-flex h-9 w-9 items-center justify-center rounded transition hover:bg-white/15",
4722
+ children: /* @__PURE__ */ jsxs("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
4723
+ /* @__PURE__ */ jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
4724
+ /* @__PURE__ */ jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
4725
+ ] })
4726
+ }
4727
+ )
4728
+ ] }),
4729
+ /* @__PURE__ */ jsx("div", { className: "min-h-0 flex-1", children: /* @__PURE__ */ jsx(MiniBoard3D, { ref: setBoard, isDark, initialState: initial }) }),
4730
+ !isMobile && /* @__PURE__ */ jsxs("footer", { className: "flex items-center justify-between border-t border-slate-200 bg-slate-50 px-3 py-2", children: [
4731
+ /* @__PURE__ */ 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." }),
4732
+ /* @__PURE__ */ jsxs("div", { className: "flex gap-2", children: [
4733
+ /* @__PURE__ */ jsx(
4734
+ "button",
4735
+ {
4736
+ onClick: onClose,
4737
+ className: "rounded border border-slate-300 bg-white px-3 py-1 text-xs font-medium text-slate-700 transition hover:bg-slate-100",
4738
+ children: "Hu\u1EF7"
4739
+ }
4740
+ ),
4741
+ /* @__PURE__ */ jsx(
4742
+ "button",
4743
+ {
4744
+ onClick: handleInsert,
4745
+ disabled: !ready,
4746
+ "data-testid": "geom3d-insert-btn",
4747
+ className: "rounded bg-blue-600 px-3 py-1 text-xs font-medium text-white transition hover:bg-blue-700 disabled:opacity-50",
4748
+ children: "Ch\xE8n"
4749
+ }
4750
+ )
4751
+ ] })
4752
+ ] })
4753
+ ]
4754
+ }
4755
+ );
4756
+ });
4757
+
4758
+ // src/stamps/geometry-3d/editor/tools.ts
4759
+ var GROUP_LABELS_3D = {
4760
+ view: "Xem",
4761
+ primitive: "C\u01A1 b\u1EA3n",
4762
+ solid: "Kh\u1ED1i \u0111a di\u1EC7n",
4763
+ curved: "Kh\u1ED1i cong",
4764
+ meta: "Kh\xE1c"
4765
+ };
4766
+ var GROUP_ORDER_3D = [
4767
+ "view",
4768
+ "primitive",
4769
+ "solid",
4770
+ "curved",
4771
+ "meta"
4772
+ ];
4773
+ var A_CODE_3D = "A".charCodeAt(0);
4774
+ function letterForGroup3D(g) {
4775
+ const idx = GROUP_ORDER_3D.indexOf(g);
4776
+ return idx >= 0 ? String.fromCharCode(A_CODE_3D + idx) : "";
4777
+ }
4049
4778
  var TOOLS_3D = [
4050
4779
  { key: "move", label: "Di chuy\u1EC3n", group: "view", stepsRequired: 0 },
4051
4780
  { key: "point", label: "\u0110i\u1EC3m", group: "primitive", stepsRequired: 1, hint: "Nh\u1EADp (x, y, z)" },
@@ -4099,8 +4828,8 @@ var TOOLS_3D = [
4099
4828
  },
4100
4829
  { key: "label", label: "Nh\xE3n", group: "meta", stepsRequired: 1, hint: "G\u1EAFn v\xE0o \u0111i\u1EC3m" }
4101
4830
  ];
4102
- function ToolButton({ toolKey, label, hint, active, onClick, icon }) {
4103
- return /* @__PURE__ */ jsx(
4831
+ function ToolButton({ toolKey, label, hint, active, onClick, icon, badge }) {
4832
+ return /* @__PURE__ */ jsxs(
4104
4833
  "button",
4105
4834
  {
4106
4835
  type: "button",
@@ -4111,10 +4840,13 @@ function ToolButton({ toolKey, label, hint, active, onClick, icon }) {
4111
4840
  "data-active": active || void 0,
4112
4841
  "data-tool": toolKey,
4113
4842
  className: [
4114
- "flex h-8 items-center justify-center rounded-md transition",
4843
+ "relative flex h-8 items-center justify-center rounded-md transition",
4115
4844
  active ? "bg-blue-600 text-white shadow-sm" : "text-slate-700 hover:bg-slate-100 hover:text-slate-900"
4116
4845
  ].join(" "),
4117
- children: icon
4846
+ children: [
4847
+ icon,
4848
+ badge
4849
+ ]
4118
4850
  }
4119
4851
  );
4120
4852
  }
@@ -4166,7 +4898,10 @@ function Shell3({ title, icon, onClose, children, isDark }) {
4166
4898
  "aria-label": title,
4167
4899
  "data-testid": "geom3d-left-panel",
4168
4900
  "data-stamp-area": "true",
4169
- 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`,
4901
+ className: [
4902
+ isDark ? "theme--dark " : "",
4903
+ "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"
4904
+ ].join(""),
4170
4905
  children: [
4171
4906
  /* @__PURE__ */ 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: [
4172
4907
  /* @__PURE__ */ jsxs("h3", { className: "flex items-center gap-2 text-sm font-semibold text-slate-800", children: [
@@ -4179,10 +4914,7 @@ function Shell3({ title, icon, onClose, children, isDark }) {
4179
4914
  onClick: onClose,
4180
4915
  "aria-label": "\u0110\xF3ng",
4181
4916
  className: "rounded p-1 text-slate-500 transition hover:bg-slate-100 hover:text-slate-800",
4182
- children: /* @__PURE__ */ jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
4183
- /* @__PURE__ */ jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
4184
- /* @__PURE__ */ jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
4185
- ] })
4917
+ children: /* @__PURE__ */ jsx(CloseIcon2, {})
4186
4918
  }
4187
4919
  )
4188
4920
  ] }),
@@ -4198,11 +4930,39 @@ function Section3({ label, children }) {
4198
4930
  ] });
4199
4931
  }
4200
4932
  var Geom3DIconHeader = /* @__PURE__ */ jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", children: /* @__PURE__ */ 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" }) });
4201
- function LeftPanel3({ handle, onResetView, onClose, isDark }) {
4202
- const [tool, setTool] = useState("move");
4203
- const [showAxes, setShowAxes] = useState(true);
4204
- const [showMesh, setShowMesh] = useState(false);
4205
- const [canUndo, setCanUndo] = useState(false);
4933
+ function CloseIcon2() {
4934
+ return /* @__PURE__ */ jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
4935
+ /* @__PURE__ */ jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
4936
+ /* @__PURE__ */ jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
4937
+ ] });
4938
+ }
4939
+ function UndoIcon2() {
4940
+ return /* @__PURE__ */ jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
4941
+ /* @__PURE__ */ jsx("polyline", { points: "3 7 3 13 9 13" }),
4942
+ /* @__PURE__ */ jsx("path", { d: "M3.51 13a9 9 0 1 0 2.13-9.36L3 7" })
4943
+ ] });
4944
+ }
4945
+ function ResetViewIcon() {
4946
+ return /* @__PURE__ */ jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
4947
+ /* @__PURE__ */ jsx("path", { d: "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" }),
4948
+ /* @__PURE__ */ jsx("path", { d: "M3 3v5h5" })
4949
+ ] });
4950
+ }
4951
+ function AxisIcon3D() {
4952
+ return /* @__PURE__ */ jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", children: [
4953
+ /* @__PURE__ */ jsx("line", { x1: "12", y1: "20", x2: "12", y2: "4" }),
4954
+ /* @__PURE__ */ jsx("line", { x1: "12", y1: "12", x2: "22", y2: "6" }),
4955
+ /* @__PURE__ */ jsx("line", { x1: "12", y1: "12", x2: "2", y2: "18" })
4956
+ ] });
4957
+ }
4958
+ function MeshIcon() {
4959
+ return /* @__PURE__ */ jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round", strokeLinejoin: "round", children: [
4960
+ /* @__PURE__ */ jsx("path", { d: "M4 8 L12 4 L20 8 L12 12 Z" }),
4961
+ /* @__PURE__ */ jsx("path", { d: "M4 8 L4 16 L12 20 L12 12" }),
4962
+ /* @__PURE__ */ jsx("path", { d: "M12 20 L20 16 L20 8" })
4963
+ ] });
4964
+ }
4965
+ function useToolHoverTooltip2() {
4206
4966
  const [hover, setHover] = useState(null);
4207
4967
  const [portalReady, setPortalReady] = useState(false);
4208
4968
  const hoverTimerRef = useRef(null);
@@ -4212,17 +4972,6 @@ function LeftPanel3({ handle, onResetView, onClose, isDark }) {
4212
4972
  if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
4213
4973
  };
4214
4974
  }, []);
4215
- useEffect(() => {
4216
- if (!handle) return;
4217
- const sync = () => {
4218
- setTool(handle.getTool());
4219
- setShowAxes(handle.getShowAxes());
4220
- setShowMesh(handle.getShowMesh());
4221
- setCanUndo(handle.canUndo());
4222
- };
4223
- sync();
4224
- return handle.subscribe(sync);
4225
- }, [handle]);
4226
4975
  const showHover = useCallback((el, t) => {
4227
4976
  if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
4228
4977
  hoverTimerRef.current = setTimeout(() => {
@@ -4237,14 +4986,45 @@ function LeftPanel3({ handle, onResetView, onClose, isDark }) {
4237
4986
  }
4238
4987
  setHover(null);
4239
4988
  }, []);
4240
- const grouped = TOOLS_3D.reduce(
4241
- (acc, t) => {
4242
- var _a;
4243
- (acc[_a = t.group] ?? (acc[_a] = [])).push(t);
4244
- return acc;
4245
- },
4246
- {}
4989
+ return { hover, portalReady, showHover, hideHover };
4990
+ }
4991
+ function useHandleState(handle) {
4992
+ const [tool, setTool] = useState("move");
4993
+ const [showAxes, setShowAxes] = useState(true);
4994
+ const [showMesh, setShowMesh] = useState(false);
4995
+ const [canUndo, setCanUndo] = useState(false);
4996
+ useEffect(() => {
4997
+ if (!handle) return;
4998
+ const sync = () => {
4999
+ setTool(handle.getTool());
5000
+ setShowAxes(handle.getShowAxes());
5001
+ setShowMesh(handle.getShowMesh());
5002
+ setCanUndo(handle.canUndo());
5003
+ };
5004
+ sync();
5005
+ return handle.subscribe(sync);
5006
+ }, [handle]);
5007
+ return { tool, showAxes, showMesh, canUndo };
5008
+ }
5009
+ function DesktopPanel(props) {
5010
+ const { handle, onResetView, onClose, isDark, chordGroup } = props;
5011
+ const { tool, showAxes, showMesh, canUndo } = useHandleState(handle);
5012
+ const { hover, portalReady, showHover, hideHover } = useToolHoverTooltip2();
5013
+ const grouped = useMemo(() => {
5014
+ return TOOLS_3D.reduce(
5015
+ (acc, t) => {
5016
+ var _a;
5017
+ (acc[_a = t.group] ?? (acc[_a] = [])).push(t);
5018
+ return acc;
5019
+ },
5020
+ {}
5021
+ );
5022
+ }, []);
5023
+ const orderedGroups = useMemo(
5024
+ () => GROUP_ORDER_3D.filter((g) => grouped[g]),
5025
+ [grouped]
4247
5026
  );
5027
+ const activeGroupTools = chordGroup ? grouped[chordGroup] ?? null : null;
4248
5028
  return /* @__PURE__ */ jsxs(Fragment, { children: [
4249
5029
  /* @__PURE__ */ jsxs(Shell3, { title: "H\xECnh h\u1ECDc 3D", icon: Geom3DIconHeader, onClose, isDark, children: [
4250
5030
  /* @__PURE__ */ jsx(Section3, { label: "B\u1ED1 c\u1EE5c", children: /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 flex-wrap text-[11px] text-slate-700", children: [
@@ -4280,10 +5060,7 @@ function LeftPanel3({ handle, onResetView, onClose, isDark }) {
4280
5060
  title: "Reset g\xF3c nh\xECn",
4281
5061
  "aria-label": "Reset view",
4282
5062
  className: "ml-auto inline-flex items-center justify-center rounded p-1 text-slate-600 transition hover:bg-slate-100 hover:text-slate-900",
4283
- children: /* @__PURE__ */ jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
4284
- /* @__PURE__ */ jsx("path", { d: "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" }),
4285
- /* @__PURE__ */ jsx("path", { d: "M3 3v5h5" })
4286
- ] })
5063
+ children: /* @__PURE__ */ jsx(ResetViewIcon, {})
4287
5064
  }
4288
5065
  ),
4289
5066
  /* @__PURE__ */ jsx(
@@ -4295,34 +5072,95 @@ function LeftPanel3({ handle, onResetView, onClose, isDark }) {
4295
5072
  title: "Ho\xE0n t\xE1c (Ctrl/Cmd+Z)",
4296
5073
  "aria-label": "Ho\xE0n t\xE1c",
4297
5074
  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",
4298
- children: /* @__PURE__ */ jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
4299
- /* @__PURE__ */ jsx("polyline", { points: "3 7 3 13 9 13" }),
4300
- /* @__PURE__ */ jsx("path", { d: "M3.51 13a9 9 0 1 0 2.13-9.36L3 7" })
4301
- ] })
5075
+ children: /* @__PURE__ */ jsx(UndoIcon2, {})
4302
5076
  }
4303
5077
  )
4304
5078
  ] }) }),
4305
- Object.entries(grouped).map(([group, tools]) => /* @__PURE__ */ jsx(Section3, { label: GROUP_LABELS_3D[group], children: /* @__PURE__ */ jsx("div", { className: "grid grid-cols-4 gap-1", children: tools.map((t) => /* @__PURE__ */ jsx(
4306
- ToolButton,
5079
+ orderedGroups.map((group) => {
5080
+ const tools = grouped[group];
5081
+ const isChordActive = chordGroup === group;
5082
+ const dimmed = chordGroup !== null && !isChordActive;
5083
+ return /* @__PURE__ */ jsxs(
5084
+ "section",
5085
+ {
5086
+ "data-chord-group": group,
5087
+ "data-chord-active": isChordActive ? "true" : "false",
5088
+ className: [
5089
+ "rounded-md transition",
5090
+ isChordActive ? "bg-blue-50 ring-1 ring-blue-400 p-1" : "p-0",
5091
+ dimmed ? "opacity-55" : "opacity-100"
5092
+ ].join(" "),
5093
+ children: [
5094
+ /* @__PURE__ */ jsxs("h4", { className: "mb-1.5 flex items-center justify-between text-[10px] font-semibold uppercase tracking-wider text-slate-500", children: [
5095
+ /* @__PURE__ */ jsx("span", { children: GROUP_LABELS_3D[group] }),
5096
+ /* @__PURE__ */ jsx(
5097
+ "span",
5098
+ {
5099
+ "data-testid": `chord-letter-${group}`,
5100
+ className: [
5101
+ "font-mono text-[10px] leading-none transition",
5102
+ isChordActive ? "text-blue-700 font-bold" : "text-slate-400"
5103
+ ].join(" "),
5104
+ children: letterForGroup3D(group)
5105
+ }
5106
+ )
5107
+ ] }),
5108
+ /* @__PURE__ */ jsx("div", { className: "grid grid-cols-4 gap-1", children: tools.map((t, i) => {
5109
+ const isActive = tool === t.key;
5110
+ return /* @__PURE__ */ jsx(
5111
+ ToolButton,
5112
+ {
5113
+ toolKey: t.key,
5114
+ label: t.label,
5115
+ hint: t.hint,
5116
+ active: isActive,
5117
+ onClick: () => handle?.setTool(t.key),
5118
+ icon: /* @__PURE__ */ jsx(
5119
+ "span",
5120
+ {
5121
+ onMouseEnter: (e) => showHover(e.currentTarget.closest("button"), t),
5122
+ onMouseLeave: hideHover,
5123
+ onFocus: (e) => showHover(e.currentTarget.closest("button"), t),
5124
+ onBlur: hideHover,
5125
+ children: ICONS_3D[t.key]
5126
+ }
5127
+ ),
5128
+ badge: /* @__PURE__ */ jsx(
5129
+ "span",
5130
+ {
5131
+ "data-testid": `chord-num-${t.key}`,
5132
+ className: [
5133
+ "pointer-events-none absolute bottom-0 right-0.5 font-mono text-[9px] leading-none transition",
5134
+ isActive ? "text-white/70" : isChordActive ? "text-blue-700 font-bold" : "text-slate-400"
5135
+ ].join(" "),
5136
+ children: i + 1
5137
+ }
5138
+ )
5139
+ },
5140
+ t.key
5141
+ );
5142
+ }) })
5143
+ ]
5144
+ },
5145
+ group
5146
+ );
5147
+ }),
5148
+ chordGroup && activeGroupTools && /* @__PURE__ */ jsxs(
5149
+ "div",
4307
5150
  {
4308
- toolKey: t.key,
4309
- label: t.label,
4310
- hint: t.hint,
4311
- active: tool === t.key,
4312
- onClick: () => handle?.setTool(t.key),
4313
- icon: /* @__PURE__ */ jsx(
4314
- "span",
4315
- {
4316
- onMouseEnter: (e) => showHover(e.currentTarget.closest("button"), t),
4317
- onMouseLeave: hideHover,
4318
- onFocus: (e) => showHover(e.currentTarget.closest("button"), t),
4319
- onBlur: hideHover,
4320
- children: ICONS_3D[t.key]
4321
- }
4322
- )
4323
- },
4324
- t.key
4325
- )) }) }, group))
5151
+ "data-testid": "chord-hint",
5152
+ className: "mt-1 rounded border border-blue-200 bg-blue-50/60 px-2 py-1 text-[11px] leading-snug text-slate-600",
5153
+ children: [
5154
+ /* @__PURE__ */ jsx("span", { className: "font-mono font-semibold text-blue-700", children: letterForGroup3D(chordGroup) }),
5155
+ /* @__PURE__ */ jsx("span", { className: "mx-1 text-slate-400", children: "\u2192" }),
5156
+ activeGroupTools.map((t, i) => /* @__PURE__ */ jsxs("span", { className: "mr-2 inline-block", children: [
5157
+ /* @__PURE__ */ jsx("span", { className: "font-mono font-semibold text-blue-700", children: i + 1 }),
5158
+ /* @__PURE__ */ jsx("span", { className: "ml-1", children: t.label })
5159
+ ] }, t.key)),
5160
+ /* @__PURE__ */ jsx("span", { className: "text-slate-400", children: "Esc hu\u1EF7" })
5161
+ ]
5162
+ }
5163
+ )
4326
5164
  ] }),
4327
5165
  portalReady && hover && typeof document !== "undefined" ? createPortal(
4328
5166
  /* @__PURE__ */ jsxs(
@@ -4346,107 +5184,71 @@ function LeftPanel3({ handle, onResetView, onClose, isDark }) {
4346
5184
  ) : null
4347
5185
  ] });
4348
5186
  }
4349
- var EditorPanel = forwardRef(function EditorPanel2({ isDark, initial, onInsert, onClose }, ref) {
4350
- const boardRef = useRef(null);
4351
- const [boardHandle, setBoardHandle] = useState(null);
4352
- const setBoard = useCallback((h) => {
4353
- boardRef.current = h;
4354
- setBoardHandle((prev) => prev === h ? prev : h);
4355
- }, []);
4356
- useImperativeHandle(
4357
- ref,
4358
- () => ({
4359
- tryInsert: () => {
4360
- const board = boardRef.current;
4361
- if (!board) return false;
4362
- const log = board.getCreationLog();
4363
- if (log.length === 0) return false;
4364
- const view = board.getViewState();
4365
- const state = {
4366
- version: 1,
4367
- bbox: board.getBbox(),
4368
- view,
4369
- showAxes: board.getShowAxes(),
4370
- showMesh: board.getShowMesh(),
4371
- elements: log
4372
- };
4373
- const snap = board.snapshotSVG();
4374
- onInsert(JSON.stringify(state), snap.svgString, snap.width, snap.height);
4375
- return true;
4376
- },
4377
- hasContent: () => (boardRef.current?.getCreationLog().length ?? 0) > 0
4378
- }),
4379
- [onInsert]
4380
- );
4381
- const handleResetView = useCallback(() => {
4382
- boardRef.current?.resetView();
5187
+ function MobilePanel(props) {
5188
+ const { handle, onResetView, isDark, drawerOpen, onDrawerClose } = props;
5189
+ const { tool, showAxes, showMesh, canUndo } = useHandleState(handle);
5190
+ const groups = useMemo(() => {
5191
+ const acc = /* @__PURE__ */ new Map();
5192
+ for (const t of TOOLS_3D) {
5193
+ if (!acc.has(t.group)) acc.set(t.group, []);
5194
+ acc.get(t.group).push(t);
5195
+ }
5196
+ return Array.from(acc.entries()).map(([group, tools]) => ({
5197
+ group,
5198
+ groupLabel: GROUP_LABELS_3D[group],
5199
+ tools: tools.map((t) => ({ key: t.key, label: t.label, icon: ICONS_3D[t.key] }))
5200
+ }));
4383
5201
  }, []);
4384
- return /* @__PURE__ */ jsxs(
4385
- "div",
5202
+ return /* @__PURE__ */ jsx(
5203
+ MobileToolDrawer,
4386
5204
  {
4387
- "data-testid": "geom3d-editor-panel",
4388
- "data-stamp-area": "true",
4389
- style: {
4390
- position: "absolute",
4391
- left: "50%",
4392
- top: "50%",
4393
- transform: "translate(-50%, -50%)",
4394
- width: 900,
4395
- height: 700,
4396
- background: "#fff",
4397
- boxShadow: "0 6px 32px rgba(0,0,0,0.2)",
4398
- borderRadius: 8,
4399
- zIndex: 10,
4400
- display: "flex",
4401
- flexDirection: "column",
4402
- overflow: "hidden"
4403
- },
4404
- children: [
4405
- /* @__PURE__ */ jsxs(
4406
- "div",
4407
- {
4408
- style: {
4409
- display: "flex",
4410
- padding: "8px 12px",
4411
- borderBottom: "1px solid #eee",
4412
- alignItems: "center"
4413
- },
4414
- children: [
4415
- /* @__PURE__ */ jsx("span", { style: { fontWeight: 600 }, children: "H\xECnh h\u1ECDc kh\xF4ng gian (3D)" }),
4416
- /* @__PURE__ */ jsx("span", { style: { flex: 1 } }),
4417
- /* @__PURE__ */ jsx("button", { type: "button", onClick: onClose, children: "\u0110\xF3ng" })
4418
- ]
4419
- }
4420
- ),
4421
- /* @__PURE__ */ jsxs("div", { style: { position: "relative", flex: 1, minHeight: 0 }, children: [
4422
- /* @__PURE__ */ jsx(
4423
- LeftPanel3,
4424
- {
4425
- handle: boardHandle,
4426
- onResetView: handleResetView,
4427
- onClose,
4428
- isDark
4429
- }
4430
- ),
4431
- /* @__PURE__ */ jsx(
4432
- "div",
4433
- {
4434
- style: {
4435
- position: "absolute",
4436
- left: 120,
4437
- top: 0,
4438
- right: 0,
4439
- bottom: 0,
4440
- overflow: "hidden"
4441
- },
4442
- children: /* @__PURE__ */ jsx(MiniBoard3D, { ref: setBoard, isDark, initialState: initial })
4443
- }
4444
- )
4445
- ] })
4446
- ]
5205
+ title: "H\xECnh h\u1ECDc 3D",
5206
+ headerIcon: Geom3DIconHeader,
5207
+ testId: "geom3d-left-panel",
5208
+ isDark,
5209
+ drawerOpen: !!drawerOpen,
5210
+ onDrawerClose: () => onDrawerClose?.(),
5211
+ chips: [
5212
+ {
5213
+ label: "Tr\u1EE5c",
5214
+ icon: /* @__PURE__ */ jsx(AxisIcon3D, {}),
5215
+ pressed: showAxes,
5216
+ onToggle: (b) => handle?.setShowAxes(b),
5217
+ testId: "toggle-axes"
5218
+ },
5219
+ {
5220
+ label: "L\u01B0\u1EDBi",
5221
+ icon: /* @__PURE__ */ jsx(MeshIcon, {}),
5222
+ pressed: showMesh,
5223
+ onToggle: (b) => handle?.setShowMesh(b),
5224
+ testId: "toggle-mesh"
5225
+ }
5226
+ ],
5227
+ actions: [
5228
+ {
5229
+ label: "Reset view",
5230
+ title: "Reset g\xF3c nh\xECn",
5231
+ icon: /* @__PURE__ */ jsx(ResetViewIcon, {}),
5232
+ onClick: onResetView
5233
+ },
5234
+ {
5235
+ label: "Ho\xE0n t\xE1c",
5236
+ title: "Ho\xE0n t\xE1c (Ctrl/Cmd+Z)",
5237
+ icon: /* @__PURE__ */ jsx(UndoIcon2, {}),
5238
+ onClick: () => handle?.undo(),
5239
+ disabled: !canUndo
5240
+ }
5241
+ ],
5242
+ groups,
5243
+ activeTool: tool,
5244
+ onToolSelect: (k) => handle?.setTool(k)
4447
5245
  }
4448
5246
  );
4449
- });
5247
+ }
5248
+ function LeftPanel3(props) {
5249
+ if (props.isMobile) return /* @__PURE__ */ jsx(MobilePanel, { ...props });
5250
+ return /* @__PURE__ */ jsx(DesktopPanel, { ...props });
5251
+ }
4450
5252
 
4451
5253
  // src/stamps/geometry-3d/serialize.ts
4452
5254
  function isGeometry3DCustomData(data) {
@@ -4531,66 +5333,1528 @@ async function renderGeometry3DSvgFromState(jsonState) {
4531
5333
  JXG.JSXGraph.freeBoard(board);
4532
5334
  } catch {
4533
5335
  }
4534
- return { svgString, width: OUTPUT_WIDTH, height: OUTPUT_HEIGHT };
4535
- } finally {
4536
- document.body.removeChild(div);
4537
- }
4538
- }
4539
- function parseInitial(editingElement) {
4540
- if (!editingElement) return null;
4541
- if (!isGeometry3DCustomData(editingElement.customData)) return null;
4542
- try {
4543
- return parseSerializedBoard3D(editingElement.customData.jsonState);
4544
- } catch {
4545
- return null;
4546
- }
5336
+ return { svgString, width: OUTPUT_WIDTH, height: OUTPUT_HEIGHT };
5337
+ } finally {
5338
+ document.body.removeChild(div);
5339
+ }
5340
+ }
5341
+ function parseInitial(editingElement) {
5342
+ if (!editingElement) return null;
5343
+ if (!isGeometry3DCustomData(editingElement.customData)) return null;
5344
+ try {
5345
+ return parseSerializedBoard3D(editingElement.customData.jsonState);
5346
+ } catch {
5347
+ return null;
5348
+ }
5349
+ }
5350
+ var Geometry3DStampHost = forwardRef(
5351
+ function Geometry3DStampHost2({ api, editingElement, onClose, isDark }, ref) {
5352
+ const editorRef = useRef(null);
5353
+ const { isMobile } = useIsMobile();
5354
+ const [drawerOpen, setDrawerOpen] = useState(false);
5355
+ const [boardHandle, setBoardHandle] = useState(null);
5356
+ const initial = useMemo(
5357
+ () => parseInitial(editingElement),
5358
+ [editingElement]
5359
+ );
5360
+ const handleBoardReady = useCallback((h) => {
5361
+ setBoardHandle((prev) => prev === h ? prev : h);
5362
+ }, []);
5363
+ const { chordGroup } = useChordShortcut({
5364
+ groupOrder: GROUP_ORDER_3D,
5365
+ tools: TOOLS_3D,
5366
+ onSelect: (key) => boardHandle?.setTool(key),
5367
+ enabled: !isMobile
5368
+ });
5369
+ const handleResetView = useCallback(() => {
5370
+ boardHandle?.resetView();
5371
+ }, [boardHandle]);
5372
+ const handleInsert = useCallback(
5373
+ async (jsonState, svgString, width, height) => {
5374
+ if (!api) return;
5375
+ await insertStampImage(api, {
5376
+ svgString,
5377
+ makeCustomData: () => ({
5378
+ kind: "geometry3d",
5379
+ version: 1,
5380
+ jsonState,
5381
+ svgWidth: width,
5382
+ svgHeight: height
5383
+ }),
5384
+ editingElementId: editingElement?.id ?? null
5385
+ });
5386
+ onClose();
5387
+ },
5388
+ [api, editingElement, onClose]
5389
+ );
5390
+ useImperativeHandle(
5391
+ ref,
5392
+ () => ({
5393
+ tryInsert: () => editorRef.current?.tryInsert() ?? false,
5394
+ hasContent: () => editorRef.current?.hasContent() ?? false
5395
+ }),
5396
+ []
5397
+ );
5398
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
5399
+ /* @__PURE__ */ jsx(
5400
+ LeftPanel3,
5401
+ {
5402
+ handle: boardHandle,
5403
+ onResetView: handleResetView,
5404
+ onClose,
5405
+ isDark,
5406
+ isMobile,
5407
+ drawerOpen,
5408
+ onDrawerClose: () => setDrawerOpen(false),
5409
+ chordGroup
5410
+ }
5411
+ ),
5412
+ /* @__PURE__ */ jsx(
5413
+ EditorPanel,
5414
+ {
5415
+ ref: editorRef,
5416
+ isDark,
5417
+ initial,
5418
+ onInsert: handleInsert,
5419
+ onClose,
5420
+ isMobile,
5421
+ withLeftPanel: !isMobile,
5422
+ onBoardReady: handleBoardReady,
5423
+ onOpenDrawer: () => setDrawerOpen(true)
5424
+ }
5425
+ )
5426
+ ] });
5427
+ }
5428
+ );
5429
+ var Geometry3DIcon = /* @__PURE__ */ jsxs(
5430
+ "svg",
5431
+ {
5432
+ width: "20",
5433
+ height: "20",
5434
+ viewBox: "0 0 24 24",
5435
+ fill: "none",
5436
+ stroke: "currentColor",
5437
+ strokeWidth: "1.6",
5438
+ strokeLinecap: "round",
5439
+ strokeLinejoin: "round",
5440
+ "aria-hidden": "true",
5441
+ children: [
5442
+ /* @__PURE__ */ jsx("path", { d: "M12 3 L20 8 L20 16 L12 21 L4 16 L4 8 Z" }),
5443
+ /* @__PURE__ */ jsx("path", { d: "M12 3 L12 21 M4 8 L12 12 L20 8 M4 16 L12 12 L20 16" })
5444
+ ]
5445
+ }
5446
+ );
5447
+ var geometry3dStamp = {
5448
+ kind: "geometry3d",
5449
+ shortcutKey: "d",
5450
+ toolbarLabel: "D",
5451
+ toolbarTitle: "H\xECnh 3D (D)",
5452
+ toolbarIcon: Geometry3DIcon,
5453
+ toolbarTestId: "stamp-toolbar-geometry3d",
5454
+ matchesCustomData: isGeometry3DCustomData,
5455
+ async renderSvgFromCustomData(data) {
5456
+ if (!isGeometry3DCustomData(data)) {
5457
+ throw new Error("geometry3dStamp.renderSvgFromCustomData: customData kh\xF4ng ph\u1EA3i geometry3d");
5458
+ }
5459
+ const { svgString } = await renderGeometry3DSvgFromState(data.jsonState);
5460
+ return svgString;
5461
+ },
5462
+ restoreFileFromCustomData: async (element) => {
5463
+ const data = element.customData;
5464
+ const fileId = element.fileId;
5465
+ if (!data || !fileId) return null;
5466
+ if (!isGeometry3DCustomData(data)) return null;
5467
+ try {
5468
+ const { svgString } = await renderGeometry3DSvgFromState(data.jsonState);
5469
+ const dataURL = `data:image/svg+xml;base64,${typeof btoa !== "undefined" ? btoa(unescape(encodeURIComponent(svgString))) : Buffer.from(svgString).toString("base64")}`;
5470
+ return { fileId, dataURL, mimeType: "image/svg+xml" };
5471
+ } catch {
5472
+ return null;
5473
+ }
5474
+ },
5475
+ Host: Geometry3DStampHost
5476
+ };
5477
+
5478
+ // src/stamps/graph-2d/editor/tools.ts
5479
+ var GRAPH_TOOLS = [
5480
+ { id: "move", label: "Di chuy\u1EC3n", title: "Di chuy\u1EC3n / ch\u1ECDn" },
5481
+ { 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" },
5482
+ { id: "intersect", label: "Giao \u0111i\u1EC3m", title: "\u0110\xE1nh d\u1EA5u giao \u0111i\u1EC3m 2 \u0111\u1ED3 th\u1ECB" },
5483
+ { id: "tangent", label: "Ti\u1EBFp tuy\u1EBFn", title: "V\u1EBD ti\u1EBFp tuy\u1EBFn t\u1EA1i \u0111i\u1EC3m tr\xEAn \u0111\u1ED3 th\u1ECB" }
5484
+ ];
5485
+ function FunctionRow(props) {
5486
+ const { id, name, expression, color, visible, error } = props;
5487
+ const [draft, setDraft] = useState(expression);
5488
+ useEffect(() => {
5489
+ setDraft(expression);
5490
+ }, [expression]);
5491
+ const commit = () => {
5492
+ if (draft !== expression) props.onExpressionCommit(draft);
5493
+ };
5494
+ const handleKeyDown = (e) => {
5495
+ if (e.key === "Enter") {
5496
+ e.preventDefault();
5497
+ commit();
5498
+ e.target.blur();
5499
+ } else if (e.key === "Escape") {
5500
+ setDraft(expression);
5501
+ e.target.blur();
5502
+ }
5503
+ };
5504
+ const handleBlur = (_) => commit();
5505
+ return /* @__PURE__ */ jsxs("div", { className: `graph-function-row${error ? " is-error" : ""}`, "data-testid": `graph-function-row-${id}`, children: [
5506
+ /* @__PURE__ */ jsx(
5507
+ "span",
5508
+ {
5509
+ className: "graph-function-color",
5510
+ style: { backgroundColor: color },
5511
+ "aria-hidden": "true"
5512
+ }
5513
+ ),
5514
+ /* @__PURE__ */ jsxs("span", { className: "graph-function-name", "data-testid": `graph-function-name-${id}`, children: [
5515
+ name,
5516
+ "(x) ="
5517
+ ] }),
5518
+ /* @__PURE__ */ jsx(
5519
+ "input",
5520
+ {
5521
+ "aria-label": "Bi\u1EC3u th\u1EE9c",
5522
+ className: "graph-function-input",
5523
+ type: "text",
5524
+ value: draft,
5525
+ onChange: (e) => setDraft(e.target.value),
5526
+ onKeyDown: handleKeyDown,
5527
+ onBlur: handleBlur,
5528
+ spellCheck: false,
5529
+ autoCorrect: "off",
5530
+ autoCapitalize: "off"
5531
+ }
5532
+ ),
5533
+ /* @__PURE__ */ jsx(
5534
+ "button",
5535
+ {
5536
+ type: "button",
5537
+ "aria-label": "\u1EA8n/hi\u1EC7n \u0111\u1ED3 th\u1ECB",
5538
+ className: `graph-function-eye${visible ? "" : " is-hidden"}`,
5539
+ onClick: props.onToggleVisible,
5540
+ children: visible ? "\u{1F441}" : "\u2298"
5541
+ }
5542
+ ),
5543
+ /* @__PURE__ */ jsx(
5544
+ "button",
5545
+ {
5546
+ type: "button",
5547
+ "aria-label": "Xo\xE1 \u0111\u1ED3 th\u1ECB",
5548
+ className: "graph-function-remove",
5549
+ onClick: props.onRemove,
5550
+ children: "\u2715"
5551
+ }
5552
+ ),
5553
+ error ? /* @__PURE__ */ jsx("div", { className: "graph-function-error", children: error }) : null
5554
+ ] });
5555
+ }
5556
+ function SliderRow(props) {
5557
+ const { name, value, min, max, step } = props;
5558
+ return /* @__PURE__ */ jsxs("div", { className: "graph-slider-row", "data-testid": `graph-slider-row-${name}`, children: [
5559
+ /* @__PURE__ */ jsxs("div", { className: "graph-slider-header", children: [
5560
+ /* @__PURE__ */ jsx("span", { className: "graph-slider-name", children: name }),
5561
+ /* @__PURE__ */ jsxs("span", { className: "graph-slider-value", children: [
5562
+ "= ",
5563
+ value.toFixed(2)
5564
+ ] }),
5565
+ /* @__PURE__ */ jsx(
5566
+ "button",
5567
+ {
5568
+ type: "button",
5569
+ "aria-label": `Xo\xE1 tham s\u1ED1 ${name}`,
5570
+ className: "graph-slider-remove",
5571
+ onClick: props.onRemove,
5572
+ children: "\u2715"
5573
+ }
5574
+ )
5575
+ ] }),
5576
+ /* @__PURE__ */ jsx(
5577
+ "input",
5578
+ {
5579
+ type: "range",
5580
+ "aria-label": `Slider ${name}`,
5581
+ min,
5582
+ max,
5583
+ step,
5584
+ value,
5585
+ onChange: (e) => props.onChange(parseFloat(e.target.value)),
5586
+ className: "graph-slider-input"
5587
+ }
5588
+ ),
5589
+ /* @__PURE__ */ jsxs("div", { className: "graph-slider-range", children: [
5590
+ /* @__PURE__ */ jsx("span", { children: min }),
5591
+ /* @__PURE__ */ jsx("span", { children: max })
5592
+ ] })
5593
+ ] });
5594
+ }
5595
+
5596
+ // src/stamps/graph-2d/colors.ts
5597
+ var GRAPH_PALETTE = [
5598
+ "#2563eb",
5599
+ // blue
5600
+ "#dc2626",
5601
+ // red
5602
+ "#16a34a",
5603
+ // green
5604
+ "#9333ea",
5605
+ // purple
5606
+ "#ea580c",
5607
+ // orange
5608
+ "#0891b2",
5609
+ // cyan
5610
+ "#db2777",
5611
+ // pink
5612
+ "#65a30d"
5613
+ // lime
5614
+ ];
5615
+ var FUNCTION_NAMES = ["f", "g", "h", "i", "j", "k", "l", "m"];
5616
+ var MAX_FUNCTIONS = 8;
5617
+ var MAX_PARAMETERS = 8;
5618
+ function nextColor(usedColors) {
5619
+ for (const c of GRAPH_PALETTE) {
5620
+ if (!usedColors.includes(c)) return c;
5621
+ }
5622
+ return GRAPH_PALETTE[usedColors.length % GRAPH_PALETTE.length];
5623
+ }
5624
+ function nextFunctionName(usedNames) {
5625
+ for (const n of FUNCTION_NAMES) {
5626
+ if (!usedNames.includes(n)) return n;
5627
+ }
5628
+ return FUNCTION_NAMES[usedNames.length % FUNCTION_NAMES.length];
5629
+ }
5630
+ function AlgebraView(props) {
5631
+ const { graph, errors } = props;
5632
+ const atMax = graph.functions.length >= MAX_FUNCTIONS;
5633
+ return /* @__PURE__ */ jsxs("div", { className: "graph-algebra-view", children: [
5634
+ /* @__PURE__ */ jsxs("div", { className: "graph-algebra-section", children: [
5635
+ graph.functions.map((f) => /* @__PURE__ */ jsx(
5636
+ FunctionRow,
5637
+ {
5638
+ id: f.id,
5639
+ name: f.name,
5640
+ expression: f.expression,
5641
+ color: f.color,
5642
+ visible: f.visible,
5643
+ error: errors[f.id] ?? null,
5644
+ onExpressionCommit: (expr) => props.onCommitFunctionExpr(f.id, expr),
5645
+ onToggleVisible: () => props.onToggleFunctionVisible(f.id),
5646
+ onRemove: () => props.onRemoveFunction(f.id)
5647
+ },
5648
+ f.id
5649
+ )),
5650
+ /* @__PURE__ */ jsx(
5651
+ "button",
5652
+ {
5653
+ type: "button",
5654
+ "aria-label": "Th\xEAm h\xE0m s\u1ED1",
5655
+ className: "graph-algebra-add",
5656
+ onClick: props.onAddFunctionDraft,
5657
+ disabled: atMax,
5658
+ children: "+ Th\xEAm h\xE0m"
5659
+ }
5660
+ )
5661
+ ] }),
5662
+ graph.parameters.length > 0 ? /* @__PURE__ */ jsx("div", { className: "graph-algebra-section graph-algebra-parameters", children: graph.parameters.map((p) => /* @__PURE__ */ jsx(
5663
+ SliderRow,
5664
+ {
5665
+ name: p.name,
5666
+ value: p.value,
5667
+ min: p.min,
5668
+ max: p.max,
5669
+ step: p.step,
5670
+ onChange: (v) => props.onParameterChange(p.name, v),
5671
+ onRangeChange: (min, max, step) => props.onParameterRangeChange(p.name, min, max, step),
5672
+ onRemove: () => props.onRemoveParameter(p.name)
5673
+ },
5674
+ p.name
5675
+ )) }) : null
5676
+ ] });
5677
+ }
5678
+ var GraphIconHeader = /* @__PURE__ */ jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", children: [
5679
+ /* @__PURE__ */ jsx("path", { d: "M3 21 V3" }),
5680
+ /* @__PURE__ */ jsx("path", { d: "M3 21 H21" }),
5681
+ /* @__PURE__ */ jsx("path", { d: "M5 19 C8 5, 14 5, 19 17" })
5682
+ ] });
5683
+ function CloseIcon3() {
5684
+ return /* @__PURE__ */ jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
5685
+ /* @__PURE__ */ jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
5686
+ /* @__PURE__ */ jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
5687
+ ] });
5688
+ }
5689
+ function UndoIcon3() {
5690
+ return /* @__PURE__ */ jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
5691
+ /* @__PURE__ */ jsx("polyline", { points: "3 7 3 13 9 13" }),
5692
+ /* @__PURE__ */ jsx("path", { d: "M3.51 13a9 9 0 1 0 2.13-9.36L3 7" })
5693
+ ] });
5694
+ }
5695
+ function ResetViewIcon2() {
5696
+ return /* @__PURE__ */ jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
5697
+ /* @__PURE__ */ jsx("circle", { cx: "12", cy: "12", r: "9" }),
5698
+ /* @__PURE__ */ jsx("line", { x1: "12", y1: "3", x2: "12", y2: "21" }),
5699
+ /* @__PURE__ */ jsx("line", { x1: "3", y1: "12", x2: "21", y2: "12" })
5700
+ ] });
5701
+ }
5702
+ function MoveIcon() {
5703
+ return /* @__PURE__ */ jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", children: /* @__PURE__ */ jsx("path", { d: "M3 4 L9 4 L9 9 L4 9 Z" }) });
5704
+ }
5705
+ function PointOnCurveIcon() {
5706
+ return /* @__PURE__ */ jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round", strokeLinejoin: "round", children: [
5707
+ /* @__PURE__ */ jsx("path", { d: "M3 17 C7 8, 14 8, 21 14" }),
5708
+ /* @__PURE__ */ jsx("circle", { cx: "12", cy: "11", r: "2.2", fill: "currentColor", stroke: "none" })
5709
+ ] });
5710
+ }
5711
+ function IntersectIcon() {
5712
+ return /* @__PURE__ */ jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round", strokeLinejoin: "round", children: [
5713
+ /* @__PURE__ */ jsx("path", { d: "M3 17 C8 5, 14 5, 21 17" }),
5714
+ /* @__PURE__ */ jsx("path", { d: "M3 5 C8 17, 14 17, 21 5" }),
5715
+ /* @__PURE__ */ jsx("circle", { cx: "12", cy: "11", r: "1.6", fill: "currentColor", stroke: "none" })
5716
+ ] });
5717
+ }
5718
+ function TangentIcon() {
5719
+ return /* @__PURE__ */ jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round", strokeLinejoin: "round", children: [
5720
+ /* @__PURE__ */ jsx("path", { d: "M3 17 C8 7, 14 7, 21 16" }),
5721
+ /* @__PURE__ */ jsx("line", { x1: "4", y1: "14", x2: "20", y2: "6" }),
5722
+ /* @__PURE__ */ jsx("circle", { cx: "12", cy: "10", r: "1.8", fill: "currentColor", stroke: "none" })
5723
+ ] });
5724
+ }
5725
+ var TOOL_ICONS = {
5726
+ move: /* @__PURE__ */ jsx(MoveIcon, {}),
5727
+ "point-on-curve": /* @__PURE__ */ jsx(PointOnCurveIcon, {}),
5728
+ intersect: /* @__PURE__ */ jsx(IntersectIcon, {}),
5729
+ tangent: /* @__PURE__ */ jsx(TangentIcon, {})
5730
+ };
5731
+ function Section4({ label, children }) {
5732
+ return /* @__PURE__ */ jsxs("section", { children: [
5733
+ /* @__PURE__ */ jsx("h4", { className: "mb-1.5 text-[10px] font-semibold uppercase tracking-wider text-slate-500", children: label }),
5734
+ children
5735
+ ] });
5736
+ }
5737
+ function PanelBody(props) {
5738
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
5739
+ /* @__PURE__ */ jsx(Section4, { label: "B\u1ED1 c\u1EE5c", children: /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 flex-wrap text-[11px] text-slate-700", children: [
5740
+ /* @__PURE__ */ jsxs("label", { className: "inline-flex select-none items-center gap-1.5", children: [
5741
+ /* @__PURE__ */ jsx(
5742
+ "input",
5743
+ {
5744
+ type: "checkbox",
5745
+ checked: props.showAxis,
5746
+ onChange: (e) => props.onShowAxisChange(e.target.checked),
5747
+ "data-testid": "toggle-axis"
5748
+ }
5749
+ ),
5750
+ "Tr\u1EE5c"
5751
+ ] }),
5752
+ /* @__PURE__ */ jsxs("label", { className: "inline-flex select-none items-center gap-1.5", children: [
5753
+ /* @__PURE__ */ jsx(
5754
+ "input",
5755
+ {
5756
+ type: "checkbox",
5757
+ checked: props.showGrid,
5758
+ onChange: (e) => props.onShowGridChange(e.target.checked),
5759
+ "data-testid": "toggle-grid"
5760
+ }
5761
+ ),
5762
+ "L\u01B0\u1EDBi"
5763
+ ] }),
5764
+ /* @__PURE__ */ jsx(
5765
+ "button",
5766
+ {
5767
+ type: "button",
5768
+ onClick: props.onResetView,
5769
+ title: "\u0110\u1EB7t l\u1EA1i t\u1EA7m nh\xECn",
5770
+ "aria-label": "\u0110\u1EB7t l\u1EA1i t\u1EA7m nh\xECn",
5771
+ className: "ml-auto inline-flex items-center justify-center rounded p-1 text-slate-600 transition hover:bg-slate-100 hover:text-slate-900",
5772
+ children: /* @__PURE__ */ jsx(ResetViewIcon2, {})
5773
+ }
5774
+ ),
5775
+ /* @__PURE__ */ jsx(
5776
+ "button",
5777
+ {
5778
+ type: "button",
5779
+ onClick: props.onUndo,
5780
+ disabled: !props.canUndo,
5781
+ title: "Ho\xE0n t\xE1c (Ctrl/Cmd+Z)",
5782
+ "aria-label": "Ho\xE0n t\xE1c",
5783
+ 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",
5784
+ children: /* @__PURE__ */ jsx(UndoIcon3, {})
5785
+ }
5786
+ )
5787
+ ] }) }),
5788
+ /* @__PURE__ */ jsx(Section4, { label: "C\xF4ng c\u1EE5", children: /* @__PURE__ */ jsx("div", { className: "grid grid-cols-4 gap-1", children: GRAPH_TOOLS.map((t) => {
5789
+ const isActive = props.activeTool === t.id;
5790
+ return /* @__PURE__ */ jsx(
5791
+ "button",
5792
+ {
5793
+ type: "button",
5794
+ "aria-label": t.title,
5795
+ title: t.title,
5796
+ "aria-pressed": isActive,
5797
+ onClick: () => props.onToolChange(t.id),
5798
+ "data-testid": `graph-tool-${t.id}`,
5799
+ className: [
5800
+ "flex h-8 items-center justify-center rounded-md transition",
5801
+ isActive ? "bg-orange-600 text-white shadow-sm" : "text-slate-700 hover:bg-slate-100 hover:text-slate-900"
5802
+ ].join(" "),
5803
+ children: TOOL_ICONS[t.id]
5804
+ },
5805
+ t.id
5806
+ );
5807
+ }) }) }),
5808
+ /* @__PURE__ */ jsx(Section4, { label: "H\xE0m s\u1ED1", children: /* @__PURE__ */ jsx(
5809
+ AlgebraView,
5810
+ {
5811
+ graph: props.graph,
5812
+ errors: props.errors,
5813
+ onAddFunctionDraft: props.onAddFunctionDraft,
5814
+ onCommitFunctionExpr: props.onCommitFunctionExpr,
5815
+ onToggleFunctionVisible: props.onToggleFunctionVisible,
5816
+ onRemoveFunction: props.onRemoveFunction,
5817
+ onParameterChange: props.onParameterChange,
5818
+ onParameterRangeChange: props.onParameterRangeChange,
5819
+ onRemoveParameter: props.onRemoveParameter
5820
+ }
5821
+ ) })
5822
+ ] });
5823
+ }
5824
+ function GraphLeftPanel(props) {
5825
+ const { isMobile, drawerOpen, isDark, onClose, onDrawerClose } = props;
5826
+ if (isMobile && !drawerOpen) return null;
5827
+ const handleClose = isMobile ? onDrawerClose : onClose;
5828
+ return /* @__PURE__ */ jsxs(
5829
+ "aside",
5830
+ {
5831
+ role: "complementary",
5832
+ "aria-label": "\u0110\u1ED3 th\u1ECB 2D",
5833
+ "data-testid": "graph-left-panel",
5834
+ "data-stamp-area": "true",
5835
+ className: [
5836
+ isDark ? "theme--dark " : "",
5837
+ 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"
5838
+ ].join(" "),
5839
+ children: [
5840
+ /* @__PURE__ */ 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: [
5841
+ /* @__PURE__ */ jsxs("h3", { className: "flex items-center gap-2 text-sm font-semibold text-slate-800", children: [
5842
+ /* @__PURE__ */ jsx("span", { className: "text-base leading-none", children: GraphIconHeader }),
5843
+ "\u0110\u1ED3 th\u1ECB 2D"
5844
+ ] }),
5845
+ /* @__PURE__ */ jsx(
5846
+ "button",
5847
+ {
5848
+ onClick: handleClose,
5849
+ "aria-label": "\u0110\xF3ng",
5850
+ className: "rounded p-1 text-slate-500 transition hover:bg-slate-100 hover:text-slate-800",
5851
+ children: /* @__PURE__ */ jsx(CloseIcon3, {})
5852
+ }
5853
+ )
5854
+ ] }),
5855
+ /* @__PURE__ */ jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-3 space-y-4", children: /* @__PURE__ */ jsx(PanelBody, { ...props }) })
5856
+ ]
5857
+ }
5858
+ );
5859
+ }
5860
+
5861
+ // src/stamps/graph-2d/parser.ts
5862
+ var ALLOWED_FUNCTIONS = /* @__PURE__ */ new Set([
5863
+ "sin",
5864
+ "cos",
5865
+ "tan",
5866
+ "asin",
5867
+ "acos",
5868
+ "atan",
5869
+ "log",
5870
+ "ln",
5871
+ "exp",
5872
+ "sqrt",
5873
+ "abs",
5874
+ "floor",
5875
+ "ceil",
5876
+ "round"
5877
+ ]);
5878
+ var ALLOWED_CHARS = /^[a-zA-Z0-9_.+\-*/^()\s,]+$/;
5879
+ var IDENTIFIER_RE = /[a-zA-Z][a-zA-Z0-9_]*/g;
5880
+ var SUGGESTIONS = {
5881
+ tg: "tan",
5882
+ arcsin: "asin",
5883
+ arccos: "acos",
5884
+ arctan: "atan"
5885
+ };
5886
+ function errResult(message) {
5887
+ return { ok: false, error: message, freeVars: /* @__PURE__ */ new Set() };
5888
+ }
5889
+ function validate(expr) {
5890
+ const trimmed = expr.trim();
5891
+ if (!trimmed) return errResult("Bi\u1EC3u th\u1EE9c r\u1ED7ng");
5892
+ if (!ALLOWED_CHARS.test(trimmed)) return errResult("K\xFD t\u1EF1 kh\xF4ng h\u1EE3p l\u1EC7");
5893
+ const ids = trimmed.match(IDENTIFIER_RE) ?? [];
5894
+ const freeVars = /* @__PURE__ */ new Set();
5895
+ for (const id of ids) {
5896
+ if (id === "x" || id === "pi" || id === "e") continue;
5897
+ if (ALLOWED_FUNCTIONS.has(id)) continue;
5898
+ if (id.length === 1) {
5899
+ freeVars.add(id);
5900
+ continue;
5901
+ }
5902
+ const hint = SUGGESTIONS[id];
5903
+ return errResult(
5904
+ 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}"`
5905
+ );
5906
+ }
5907
+ try {
5908
+ const paramSubs = Object.fromEntries([...freeVars].map((v) => [v, 1]));
5909
+ const rewritten = rewriteToJs(trimmed, paramSubs);
5910
+ new Function("x", `return (${rewritten})`);
5911
+ } catch {
5912
+ return errResult("L\u1ED7i c\xFA ph\xE1p");
5913
+ }
5914
+ return { ok: true, freeVars };
5915
+ }
5916
+ var FUNCTION_REPLACEMENTS = [
5917
+ // longest first để tránh substring conflict (asin trước sin)
5918
+ ["asin", "Math.asin"],
5919
+ ["acos", "Math.acos"],
5920
+ ["atan", "Math.atan"],
5921
+ ["sqrt", "Math.sqrt"],
5922
+ ["floor", "Math.floor"],
5923
+ ["round", "Math.round"],
5924
+ ["ceil", "Math.ceil"],
5925
+ ["sin", "Math.sin"],
5926
+ ["cos", "Math.cos"],
5927
+ ["tan", "Math.tan"],
5928
+ ["abs", "Math.abs"],
5929
+ ["exp", "Math.exp"],
5930
+ ["log", "Math.log10"],
5931
+ ["ln", "Math.log"]
5932
+ ];
5933
+ function rewriteToJs(expr, params) {
5934
+ let s = expr.replace(/\^/g, "**");
5935
+ s = s.replace(/\bpi\b/g, "Math.PI");
5936
+ s = s.replace(/\be\b/g, "Math.E");
5937
+ for (const [from, to] of FUNCTION_REPLACEMENTS) {
5938
+ s = s.replace(new RegExp(`\\b${from}\\b`, "g"), to);
5939
+ }
5940
+ for (const [name, value] of Object.entries(params)) {
5941
+ if (name.length !== 1) continue;
5942
+ s = s.replace(new RegExp(`\\b${name}\\b`, "g"), `(${value})`);
5943
+ }
5944
+ return s;
5945
+ }
5946
+ function compile(expr, paramValues) {
5947
+ const v = validate(expr);
5948
+ if (!v.ok) return { error: v.error ?? "Invalid" };
5949
+ try {
5950
+ const rewritten = rewriteToJs(expr, paramValues);
5951
+ const raw = new Function("x", `return (${rewritten})`);
5952
+ return (x) => {
5953
+ try {
5954
+ const y = raw(x);
5955
+ return typeof y === "number" ? y : NaN;
5956
+ } catch {
5957
+ return NaN;
5958
+ }
5959
+ };
5960
+ } catch (err) {
5961
+ return { error: err instanceof Error ? err.message : String(err) };
5962
+ }
5963
+ }
5964
+
5965
+ // src/stamps/graph-2d/editor/handlers.ts
5966
+ function addPointOnCurve(graph, ctx, idFactory) {
5967
+ if (!ctx.functionId) return graph;
5968
+ const point = {
5969
+ id: idFactory(),
5970
+ functionId: ctx.functionId,
5971
+ x: ctx.x
5972
+ };
5973
+ return { ...graph, points: [...graph.points, point] };
5974
+ }
5975
+ function addIntersection(graph, functionIdA, functionIdB, idFactory) {
5976
+ if (functionIdA === functionIdB) return graph;
5977
+ const exists = graph.intersections.some(
5978
+ (i) => i.functionIdA === functionIdA && i.functionIdB === functionIdB || i.functionIdA === functionIdB && i.functionIdB === functionIdA
5979
+ );
5980
+ if (exists) return graph;
5981
+ const intersection = {
5982
+ id: idFactory(),
5983
+ functionIdA,
5984
+ functionIdB
5985
+ };
5986
+ return { ...graph, intersections: [...graph.intersections, intersection] };
5987
+ }
5988
+ function numericalDerivative(expression, paramValues, x, h = 1e-4) {
5989
+ const fn = compile(expression, paramValues);
5990
+ if (typeof fn !== "function") return NaN;
5991
+ const y1 = fn(x - h);
5992
+ const y2 = fn(x + h);
5993
+ return (y2 - y1) / (2 * h);
5994
+ }
5995
+ function MiniBoard({ graph, activeTool, isDark, onBoardEvent }) {
5996
+ const containerRef = useRef(null);
5997
+ const boardRef = useRef(null);
5998
+ const curvesRef = useRef(/* @__PURE__ */ new Map());
5999
+ useEffect(() => {
6000
+ let cancelled = false;
6001
+ let createdBoard = null;
6002
+ const containerEl = containerRef.current;
6003
+ if (!containerEl) return;
6004
+ const containerId = `jxg_graph2d_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
6005
+ containerEl.id = containerId;
6006
+ (async () => {
6007
+ const JXG = (await import('jsxgraph')).default;
6008
+ if (cancelled) return;
6009
+ const opts = JXG.Options;
6010
+ if (opts) {
6011
+ opts.text = opts.text || {};
6012
+ opts.text.display = "internal";
6013
+ opts.label = opts.label || {};
6014
+ opts.label.display = "internal";
6015
+ }
6016
+ const board = JXG.JSXGraph.initBoard(containerId, {
6017
+ boundingbox: [graph.view.xMin, graph.view.yMax, graph.view.xMax, graph.view.yMin],
6018
+ axis: graph.view.showAxis,
6019
+ grid: graph.view.showGrid,
6020
+ showCopyright: false,
6021
+ showNavigation: true,
6022
+ pan: { enabled: true, needShift: false },
6023
+ zoom: { wheel: true, needShift: false },
6024
+ keepAspectRatio: false
6025
+ });
6026
+ boardRef.current = board;
6027
+ createdBoard = board;
6028
+ syncObjects(board, graph, curvesRef.current);
6029
+ board.on("boundingbox", () => {
6030
+ const bb = board.getBoundingBox();
6031
+ onBoardEvent({
6032
+ type: "view-change",
6033
+ view: {
6034
+ xMin: bb[0],
6035
+ xMax: bb[2],
6036
+ yMax: bb[1],
6037
+ yMin: bb[3],
6038
+ showAxis: graph.view.showAxis,
6039
+ showGrid: graph.view.showGrid
6040
+ }
6041
+ });
6042
+ });
6043
+ board.on("down", (ev) => {
6044
+ const usrCoords = board.getUsrCoordsOfMouse?.(ev);
6045
+ const x = usrCoords?.[0] ?? 0;
6046
+ const y = usrCoords?.[1] ?? 0;
6047
+ let functionId;
6048
+ for (const [id, ref] of curvesRef.current) {
6049
+ const obj = ref.obj;
6050
+ if (obj?.hasPoint && obj.hasPoint(ev.clientX ?? 0, ev.clientY ?? 0)) {
6051
+ functionId = id;
6052
+ break;
6053
+ }
6054
+ }
6055
+ if (functionId) onBoardEvent({ type: "click-curve", functionId, x, y });
6056
+ else onBoardEvent({ type: "click-empty", x, y });
6057
+ });
6058
+ })().catch((err) => console.error("MiniBoard init failed:", err));
6059
+ return () => {
6060
+ cancelled = true;
6061
+ try {
6062
+ if (createdBoard) __require("jsxgraph").default.JSXGraph.freeBoard(createdBoard);
6063
+ } catch {
6064
+ }
6065
+ boardRef.current = null;
6066
+ curvesRef.current.clear();
6067
+ };
6068
+ }, []);
6069
+ useEffect(() => {
6070
+ if (!boardRef.current) return;
6071
+ syncObjects(boardRef.current, graph, curvesRef.current);
6072
+ }, [graph]);
6073
+ useEffect(() => {
6074
+ const el = containerRef.current;
6075
+ if (!el) return;
6076
+ el.style.cursor = activeTool === "move" ? "" : "crosshair";
6077
+ }, [activeTool]);
6078
+ return /* @__PURE__ */ jsx(
6079
+ "div",
6080
+ {
6081
+ ref: containerRef,
6082
+ className: "graph-miniboard",
6083
+ style: { width: "100%", height: "100%", minHeight: "300px" },
6084
+ "data-testid": "graph-miniboard"
6085
+ }
6086
+ );
6087
+ }
6088
+ function paramSig(graph) {
6089
+ return graph.parameters.map((p) => `${p.name}=${p.value}`).join(",");
6090
+ }
6091
+ function syncObjects(board, graph, curves) {
6092
+ const sig = paramSig(graph);
6093
+ const paramMap = {};
6094
+ for (const p of graph.parameters) paramMap[p.name] = p.value;
6095
+ const wantedIds = new Set(graph.functions.map((f) => f.id));
6096
+ for (const [id, ref] of curves) {
6097
+ if (!wantedIds.has(id)) {
6098
+ try {
6099
+ board.removeObject(ref.obj);
6100
+ } catch {
6101
+ }
6102
+ curves.delete(id);
6103
+ }
6104
+ }
6105
+ for (const f of graph.functions) {
6106
+ const existing = curves.get(f.id);
6107
+ const needsRecreate = !existing || existing.expression !== f.expression || existing.color !== f.color || existing.visible !== f.visible || existing.paramSignature !== sig;
6108
+ if (!needsRecreate) continue;
6109
+ if (existing) {
6110
+ try {
6111
+ board.removeObject(existing.obj);
6112
+ } catch {
6113
+ }
6114
+ }
6115
+ if (!f.visible) {
6116
+ curves.delete(f.id);
6117
+ continue;
6118
+ }
6119
+ const compiled = compile(f.expression, paramMap);
6120
+ if (typeof compiled !== "function") continue;
6121
+ const domain = f.domain ?? { min: graph.view.xMin, max: graph.view.xMax };
6122
+ const obj = board.create("functiongraph", [compiled, domain.min, domain.max], {
6123
+ strokeColor: f.color,
6124
+ strokeWidth: 2,
6125
+ name: f.name,
6126
+ withLabel: false,
6127
+ highlight: false
6128
+ });
6129
+ curves.set(f.id, {
6130
+ obj,
6131
+ expression: f.expression,
6132
+ color: f.color,
6133
+ visible: f.visible,
6134
+ paramSignature: sig
6135
+ });
6136
+ }
6137
+ for (const point of graph.points) {
6138
+ const fn = graph.functions.find((f) => f.id === point.functionId);
6139
+ if (!fn || !fn.visible) continue;
6140
+ const compiled = compile(fn.expression, paramMap);
6141
+ if (typeof compiled !== "function") continue;
6142
+ const y = compiled(point.x);
6143
+ board.create("point", [point.x, y], {
6144
+ name: point.label ?? "",
6145
+ size: 3,
6146
+ fillColor: fn.color,
6147
+ strokeColor: fn.color,
6148
+ withLabel: !!point.label
6149
+ });
6150
+ }
6151
+ for (const inter of graph.intersections) {
6152
+ const fa = graph.functions.find((f) => f.id === inter.functionIdA);
6153
+ const fb = graph.functions.find((f) => f.id === inter.functionIdB);
6154
+ if (!fa || !fb || !fa.visible || !fb.visible) continue;
6155
+ const cfa = compile(fa.expression, paramMap);
6156
+ const cfb = compile(fb.expression, paramMap);
6157
+ if (typeof cfa !== "function" || typeof cfb !== "function") continue;
6158
+ const roots = scanRoots((x) => cfa(x) - cfb(x), graph.view.xMin, graph.view.xMax);
6159
+ for (const x of roots) {
6160
+ board.create("point", [x, cfa(x)], {
6161
+ size: 3,
6162
+ fillColor: "#000",
6163
+ strokeColor: "#000"
6164
+ });
6165
+ }
6166
+ }
6167
+ for (const tan of graph.tangents) {
6168
+ const pt = graph.points.find((p) => p.id === tan.pointId);
6169
+ if (!pt) continue;
6170
+ const fn = graph.functions.find((f) => f.id === pt.functionId);
6171
+ if (!fn || !fn.visible) continue;
6172
+ const slope = numericalDerivative(fn.expression, paramMap, pt.x);
6173
+ const cfn = compile(fn.expression, paramMap);
6174
+ if (typeof cfn !== "function" || !Number.isFinite(slope)) continue;
6175
+ const y0 = cfn(pt.x);
6176
+ const x1 = graph.view.xMin;
6177
+ const x2 = graph.view.xMax;
6178
+ board.create(
6179
+ "line",
6180
+ [
6181
+ [x1, slope * (x1 - pt.x) + y0],
6182
+ [x2, slope * (x2 - pt.x) + y0]
6183
+ ],
6184
+ {
6185
+ strokeColor: fn.color,
6186
+ strokeWidth: 1,
6187
+ dash: 2,
6188
+ straightFirst: false,
6189
+ straightLast: false
6190
+ }
6191
+ );
6192
+ }
6193
+ board.update();
6194
+ }
6195
+ function scanRoots(fn, xMin, xMax, samples = 200) {
6196
+ const roots = [];
6197
+ const step = (xMax - xMin) / samples;
6198
+ let prevX = xMin;
6199
+ let prevY = fn(prevX);
6200
+ for (let i = 1; i <= samples; i++) {
6201
+ const x = xMin + i * step;
6202
+ const y = fn(x);
6203
+ if (Number.isFinite(prevY) && Number.isFinite(y) && prevY * y < 0) {
6204
+ let a = prevX;
6205
+ let b = x;
6206
+ let ya = prevY;
6207
+ for (let j = 0; j < 30; j++) {
6208
+ const m = (a + b) / 2;
6209
+ const ym = fn(m);
6210
+ if (Math.abs(ym) < 1e-6) {
6211
+ a = b = m;
6212
+ break;
6213
+ }
6214
+ if (ya * ym < 0) {
6215
+ b = m;
6216
+ } else {
6217
+ a = m;
6218
+ ya = ym;
6219
+ }
6220
+ }
6221
+ roots.push((a + b) / 2);
6222
+ }
6223
+ prevX = x;
6224
+ prevY = y;
6225
+ }
6226
+ return roots;
6227
+ }
6228
+
6229
+ // src/stamps/graph-2d/serialize.ts
6230
+ var EMPTY_GRAPH = {
6231
+ version: 1,
6232
+ view: { xMin: -10, xMax: 10, yMin: -10, yMax: 10, showAxis: true, showGrid: true },
6233
+ functions: [],
6234
+ parameters: [],
6235
+ points: [],
6236
+ intersections: [],
6237
+ tangents: []
6238
+ };
6239
+ function stringifySerializedGraph(graph) {
6240
+ return JSON.stringify(graph);
6241
+ }
6242
+ function parseSerializedGraph(jsonState) {
6243
+ let raw;
6244
+ try {
6245
+ raw = JSON.parse(jsonState);
6246
+ } catch {
6247
+ return null;
6248
+ }
6249
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
6250
+ const r = raw;
6251
+ if (r.version !== 1) return null;
6252
+ if (!r.view || typeof r.view !== "object") return null;
6253
+ const v = r.view;
6254
+ 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") {
6255
+ return null;
6256
+ }
6257
+ for (const key of ["functions", "parameters", "points", "intersections", "tangents"]) {
6258
+ if (!Array.isArray(r[key])) return null;
6259
+ }
6260
+ return raw;
6261
+ }
6262
+
6263
+ // src/stamps/graph-2d/renderObjects.ts
6264
+ function renderGraphObjects(board, graph) {
6265
+ const paramMap = {};
6266
+ for (const p of graph.parameters) paramMap[p.name] = p.value;
6267
+ for (const f of graph.functions) {
6268
+ if (!f.visible) continue;
6269
+ const compiled = compile(f.expression, paramMap);
6270
+ if (typeof compiled !== "function") continue;
6271
+ const domain = f.domain ?? { min: graph.view.xMin, max: graph.view.xMax };
6272
+ board.create("functiongraph", [compiled, domain.min, domain.max], {
6273
+ strokeColor: f.color,
6274
+ strokeWidth: 2,
6275
+ name: f.name,
6276
+ withLabel: false,
6277
+ highlight: false
6278
+ });
6279
+ }
6280
+ for (const point of graph.points) {
6281
+ const fn = graph.functions.find((f) => f.id === point.functionId);
6282
+ if (!fn || !fn.visible) continue;
6283
+ const compiled = compile(fn.expression, paramMap);
6284
+ if (typeof compiled !== "function") continue;
6285
+ const y = compiled(point.x);
6286
+ board.create("point", [point.x, y], {
6287
+ name: point.label ?? "",
6288
+ size: 3,
6289
+ fillColor: fn.color,
6290
+ strokeColor: fn.color,
6291
+ withLabel: !!point.label
6292
+ });
6293
+ }
6294
+ for (const inter of graph.intersections) {
6295
+ const fa = graph.functions.find((f) => f.id === inter.functionIdA);
6296
+ const fb = graph.functions.find((f) => f.id === inter.functionIdB);
6297
+ if (!fa || !fb || !fa.visible || !fb.visible) continue;
6298
+ const cfa = compile(fa.expression, paramMap);
6299
+ const cfb = compile(fb.expression, paramMap);
6300
+ if (typeof cfa !== "function" || typeof cfb !== "function") continue;
6301
+ const roots = scanRoots2((x) => cfa(x) - cfb(x), graph.view.xMin, graph.view.xMax);
6302
+ for (const x of roots) {
6303
+ board.create("point", [x, cfa(x)], {
6304
+ size: 3,
6305
+ fillColor: "#000",
6306
+ strokeColor: "#000"
6307
+ });
6308
+ }
6309
+ }
6310
+ for (const tan of graph.tangents) {
6311
+ const pt = graph.points.find((p) => p.id === tan.pointId);
6312
+ if (!pt) continue;
6313
+ const fn = graph.functions.find((f) => f.id === pt.functionId);
6314
+ if (!fn || !fn.visible) continue;
6315
+ const slope = numericalDerivative(fn.expression, paramMap, pt.x);
6316
+ const cfn = compile(fn.expression, paramMap);
6317
+ if (typeof cfn !== "function" || !Number.isFinite(slope)) continue;
6318
+ const y0 = cfn(pt.x);
6319
+ const x1 = graph.view.xMin;
6320
+ const x2 = graph.view.xMax;
6321
+ board.create(
6322
+ "line",
6323
+ [
6324
+ [x1, slope * (x1 - pt.x) + y0],
6325
+ [x2, slope * (x2 - pt.x) + y0]
6326
+ ],
6327
+ {
6328
+ strokeColor: fn.color,
6329
+ strokeWidth: 1,
6330
+ dash: 2,
6331
+ straightFirst: false,
6332
+ straightLast: false
6333
+ }
6334
+ );
6335
+ }
6336
+ }
6337
+ function scanRoots2(fn, xMin, xMax, samples = 200) {
6338
+ const roots = [];
6339
+ const step = (xMax - xMin) / samples;
6340
+ let prevX = xMin;
6341
+ let prevY = fn(prevX);
6342
+ for (let i = 1; i <= samples; i++) {
6343
+ const x = xMin + i * step;
6344
+ const y = fn(x);
6345
+ if (Number.isFinite(prevY) && Number.isFinite(y) && prevY * y < 0) {
6346
+ let a = prevX;
6347
+ let b = x;
6348
+ let ya = prevY;
6349
+ for (let j = 0; j < 30; j++) {
6350
+ const m = (a + b) / 2;
6351
+ const ym = fn(m);
6352
+ if (Math.abs(ym) < 1e-6) {
6353
+ a = b = m;
6354
+ break;
6355
+ }
6356
+ if (ya * ym < 0) {
6357
+ b = m;
6358
+ } else {
6359
+ a = m;
6360
+ ya = ym;
6361
+ }
6362
+ }
6363
+ roots.push((a + b) / 2);
6364
+ }
6365
+ prevX = x;
6366
+ prevY = y;
6367
+ }
6368
+ return roots;
6369
+ }
6370
+
6371
+ // src/stamps/graph-2d/render.ts
6372
+ async function renderGraph2dSvgFromState(jsonState) {
6373
+ const parsed = parseSerializedGraph(jsonState);
6374
+ if (!parsed) throw new Error("renderGraph2dSvgFromState: jsonState corrupt");
6375
+ const JXG = (await import('jsxgraph')).default;
6376
+ const opts = JXG.Options;
6377
+ if (opts) {
6378
+ opts.text = opts.text || {};
6379
+ opts.text.display = "internal";
6380
+ opts.text.useASCIIMathML = false;
6381
+ opts.text.useMathJax = false;
6382
+ opts.text.useKatex = false;
6383
+ opts.label = opts.label || {};
6384
+ opts.label.display = "internal";
6385
+ }
6386
+ const container = document.createElement("div");
6387
+ container.id = `jxg_graph2d_off_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
6388
+ container.style.cssText = "position:absolute;top:-99999px;left:-99999px;width:600px;height:400px;visibility:hidden;pointer-events:none;";
6389
+ document.body.appendChild(container);
6390
+ let board = null;
6391
+ try {
6392
+ board = JXG.JSXGraph.initBoard(container.id, {
6393
+ boundingbox: [parsed.view.xMin, parsed.view.yMax, parsed.view.xMax, parsed.view.yMin],
6394
+ axis: parsed.view.showAxis,
6395
+ grid: parsed.view.showGrid,
6396
+ showCopyright: false,
6397
+ showNavigation: false,
6398
+ keepAspectRatio: false
6399
+ });
6400
+ renderGraphObjects(board, parsed);
6401
+ board.update();
6402
+ const svgEl = container.querySelector("svg");
6403
+ if (!svgEl) throw new Error("renderGraph2dSvgFromState: no svg generated");
6404
+ return svgEl.outerHTML;
6405
+ } finally {
6406
+ try {
6407
+ if (board) JXG.JSXGraph.freeBoard(board);
6408
+ } catch {
6409
+ }
6410
+ if (container.parentNode) container.parentNode.removeChild(container);
6411
+ }
6412
+ }
6413
+ var GraphEditorPanel = forwardRef(function GraphEditorPanel2(props, ref) {
6414
+ const initialGraph = props.initialState ?? EMPTY_GRAPH;
6415
+ const graphRef = useRef(initialGraph);
6416
+ const [, forceUpdate] = useState(0);
6417
+ const [errors, setErrors] = useState({});
6418
+ const [tool, setToolState] = useState("move");
6419
+ const undoStackRef = useRef([]);
6420
+ const idCounterRef = useRef(1);
6421
+ const toolRef = useRef(tool);
6422
+ toolRef.current = tool;
6423
+ const intersectFirstRef = useRef(null);
6424
+ const propsRef = useRef(props);
6425
+ propsRef.current = props;
6426
+ const initialGraphNotifiedRef = useRef(false);
6427
+ const pushUndo = useCallback((g) => {
6428
+ undoStackRef.current.push(g);
6429
+ if (undoStackRef.current.length > 30) undoStackRef.current.shift();
6430
+ }, []);
6431
+ const setErrorsWithNotify = useCallback(
6432
+ (updater) => {
6433
+ setErrors((prev) => {
6434
+ const next = updater(prev);
6435
+ propsRef.current.onErrorsChange?.(next);
6436
+ return next;
6437
+ });
6438
+ },
6439
+ []
6440
+ );
6441
+ const notifyStateChange = useCallback((g, t) => {
6442
+ propsRef.current.onStateChange({
6443
+ tool: t,
6444
+ showAxis: g.view.showAxis,
6445
+ showGrid: g.view.showGrid,
6446
+ canUndo: undoStackRef.current.length > 0
6447
+ });
6448
+ }, []);
6449
+ const updateGraph = useCallback(
6450
+ (mutator) => {
6451
+ const prev = graphRef.current;
6452
+ pushUndo(prev);
6453
+ const next = mutator(prev);
6454
+ graphRef.current = next;
6455
+ notifyStateChange(next, toolRef.current);
6456
+ forceUpdate((n) => n + 1);
6457
+ propsRef.current.onGraphChange?.(next);
6458
+ },
6459
+ [pushUndo, notifyStateChange]
6460
+ );
6461
+ const onBoardEvent = useCallback((ev) => {
6462
+ const currentTool = toolRef.current;
6463
+ if (currentTool === "point-on-curve" && ev.type === "click-curve" && ev.functionId && ev.x !== void 0) {
6464
+ updateGraph(
6465
+ (g) => addPointOnCurve(
6466
+ g,
6467
+ { x: ev.x, y: ev.y ?? 0, functionId: ev.functionId },
6468
+ () => `p${idCounterRef.current++}`
6469
+ )
6470
+ );
6471
+ setToolState("move");
6472
+ } else if (currentTool === "intersect" && ev.type === "click-curve" && ev.functionId) {
6473
+ if (!intersectFirstRef.current) {
6474
+ intersectFirstRef.current = ev.functionId;
6475
+ } else {
6476
+ const a = intersectFirstRef.current;
6477
+ const b = ev.functionId;
6478
+ intersectFirstRef.current = null;
6479
+ updateGraph(
6480
+ (g) => addIntersection(g, a, b, () => `i${idCounterRef.current++}`)
6481
+ );
6482
+ setToolState("move");
6483
+ }
6484
+ } else if (currentTool === "tangent" && ev.type === "click-curve" && ev.functionId && ev.x !== void 0) {
6485
+ const pointId = `p${idCounterRef.current++}`;
6486
+ const tangentId = `t${idCounterRef.current++}`;
6487
+ updateGraph((g) => ({
6488
+ ...g,
6489
+ points: [...g.points, { id: pointId, functionId: ev.functionId, x: ev.x }],
6490
+ tangents: [...g.tangents, { id: tangentId, pointId }]
6491
+ }));
6492
+ setToolState("move");
6493
+ }
6494
+ }, [updateGraph]);
6495
+ useImperativeHandle(
6496
+ ref,
6497
+ () => ({
6498
+ insert: () => {
6499
+ const g = graphRef.current;
6500
+ if (g.functions.length === 0) return false;
6501
+ const jsonState = stringifySerializedGraph(g);
6502
+ renderGraph2dSvgFromState(jsonState).then((svg) => propsRef.current.onInsert(jsonState, svg)).catch((err) => console.error("Graph2D insert render failed:", err));
6503
+ return true;
6504
+ },
6505
+ hasContent: () => graphRef.current.functions.length > 0,
6506
+ setTool: (t) => {
6507
+ setToolState(t);
6508
+ const g = graphRef.current;
6509
+ propsRef.current.onStateChange({
6510
+ tool: t,
6511
+ showAxis: g.view.showAxis,
6512
+ showGrid: g.view.showGrid,
6513
+ canUndo: undoStackRef.current.length > 0
6514
+ });
6515
+ },
6516
+ setShowAxis: (b) => updateGraph((g) => ({ ...g, view: { ...g.view, showAxis: b } })),
6517
+ setShowGrid: (b) => updateGraph((g) => ({ ...g, view: { ...g.view, showGrid: b } })),
6518
+ resetView: () => updateGraph((g) => ({
6519
+ ...g,
6520
+ view: { ...g.view, xMin: -10, xMax: 10, yMin: -10, yMax: 10 }
6521
+ })),
6522
+ undo: () => {
6523
+ const prev = undoStackRef.current.pop();
6524
+ if (!prev) return;
6525
+ graphRef.current = prev;
6526
+ forceUpdate((n) => n + 1);
6527
+ propsRef.current.onStateChange({
6528
+ tool: toolRef.current,
6529
+ showAxis: prev.view.showAxis,
6530
+ showGrid: prev.view.showGrid,
6531
+ canUndo: undoStackRef.current.length > 0
6532
+ });
6533
+ propsRef.current.onGraphChange?.(prev);
6534
+ },
6535
+ addFunction: (expr) => {
6536
+ const g = graphRef.current;
6537
+ if (g.functions.length >= MAX_FUNCTIONS) {
6538
+ return { ok: false, error: `T\u1ED1i \u0111a ${MAX_FUNCTIONS} h\xE0m` };
6539
+ }
6540
+ const v = validate(expr);
6541
+ if (!v.ok) return { ok: false, error: v.error ?? "Invalid" };
6542
+ const id = `f${idCounterRef.current++}`;
6543
+ const usedNames = g.functions.map((f) => f.name);
6544
+ const usedColors = g.functions.map((f) => f.color);
6545
+ const newFn = {
6546
+ id,
6547
+ name: nextFunctionName(usedNames),
6548
+ expression: expr,
6549
+ color: nextColor(usedColors),
6550
+ visible: true
6551
+ };
6552
+ const usedParamNames = new Set(g.parameters.map((p) => p.name));
6553
+ const newParams = [];
6554
+ for (const varName of v.freeVars) {
6555
+ if (usedParamNames.has(varName)) continue;
6556
+ if (g.parameters.length + newParams.length >= MAX_PARAMETERS) break;
6557
+ newParams.push({ name: varName, value: 1, min: -5, max: 5, step: 0.1 });
6558
+ }
6559
+ updateGraph((prev) => ({
6560
+ ...prev,
6561
+ functions: [...prev.functions, newFn],
6562
+ parameters: [...prev.parameters, ...newParams]
6563
+ }));
6564
+ setErrorsWithNotify((e) => ({ ...e, [id]: null }));
6565
+ return { ok: true, id };
6566
+ },
6567
+ commitFunctionExpression: (id, expr) => {
6568
+ const g = graphRef.current;
6569
+ const v = validate(expr);
6570
+ if (!v.ok) {
6571
+ setErrorsWithNotify((e) => ({ ...e, [id]: v.error ?? "Invalid" }));
6572
+ return;
6573
+ }
6574
+ const usedParamNames = new Set(g.parameters.map((p) => p.name));
6575
+ const newParams = [];
6576
+ for (const varName of v.freeVars) {
6577
+ if (usedParamNames.has(varName)) continue;
6578
+ if (g.parameters.length + newParams.length >= MAX_PARAMETERS) break;
6579
+ newParams.push({ name: varName, value: 1, min: -5, max: 5, step: 0.1 });
6580
+ }
6581
+ updateGraph((prev) => ({
6582
+ ...prev,
6583
+ functions: prev.functions.map(
6584
+ (f) => f.id === id ? { ...f, expression: expr } : f
6585
+ ),
6586
+ parameters: [...prev.parameters, ...newParams]
6587
+ }));
6588
+ setErrorsWithNotify((e) => ({ ...e, [id]: null }));
6589
+ },
6590
+ toggleFunctionVisible: (id) => updateGraph((g) => ({
6591
+ ...g,
6592
+ functions: g.functions.map(
6593
+ (f) => f.id === id ? { ...f, visible: !f.visible } : f
6594
+ )
6595
+ })),
6596
+ removeFunction: (id) => updateGraph((g) => ({
6597
+ ...g,
6598
+ functions: g.functions.filter((f) => f.id !== id)
6599
+ })),
6600
+ // setParameter does NOT push undo — would flood the stack (slider drag)
6601
+ setParameter: (name, value) => {
6602
+ const next = {
6603
+ ...graphRef.current,
6604
+ parameters: graphRef.current.parameters.map(
6605
+ (p) => p.name === name ? { ...p, value } : p
6606
+ )
6607
+ };
6608
+ graphRef.current = next;
6609
+ forceUpdate((n) => n + 1);
6610
+ propsRef.current.onGraphChange?.(next);
6611
+ },
6612
+ setParameterRange: (name, min, max, step) => updateGraph((g) => ({
6613
+ ...g,
6614
+ parameters: g.parameters.map(
6615
+ (p) => p.name === name ? { ...p, min, max, step, value: Math.min(max, Math.max(min, p.value)) } : p
6616
+ )
6617
+ })),
6618
+ removeParameter: (name) => updateGraph((g) => ({
6619
+ ...g,
6620
+ parameters: g.parameters.filter((p) => p.name !== name)
6621
+ })),
6622
+ getGraph: () => graphRef.current,
6623
+ getErrors: () => errors
6624
+ }),
6625
+ // deps: updateGraph stable; errors changes when function errors change; setErrorsWithNotify stable
6626
+ // eslint-disable-next-line react-hooks/exhaustive-deps
6627
+ [updateGraph, errors, setErrorsWithNotify]
6628
+ );
6629
+ useEffect(() => {
6630
+ if (!initialGraphNotifiedRef.current) {
6631
+ initialGraphNotifiedRef.current = true;
6632
+ propsRef.current.onGraphChange?.(graphRef.current);
6633
+ }
6634
+ }, []);
6635
+ const graph = graphRef.current;
6636
+ const hasContent = graph.functions.length > 0;
6637
+ const handleInsert = () => {
6638
+ const g = graphRef.current;
6639
+ if (g.functions.length === 0) return;
6640
+ const jsonState = stringifySerializedGraph(g);
6641
+ renderGraph2dSvgFromState(jsonState).then((svg) => propsRef.current.onInsert(jsonState, svg)).catch((err) => console.error("Graph2D insert render failed:", err));
6642
+ };
6643
+ const { isMobile, isDark, withLeftPanel } = props;
6644
+ const wrapperStyle = isMobile ? { position: "fixed", inset: 0, zIndex: 40 } : {
6645
+ position: "absolute",
6646
+ top: "50%",
6647
+ left: withLeftPanel ? "calc(50% + 120px)" : "50%",
6648
+ transform: "translate(-50%, -50%)",
6649
+ zIndex: 40
6650
+ };
6651
+ return /* @__PURE__ */ jsxs(
6652
+ "div",
6653
+ {
6654
+ role: "dialog",
6655
+ "aria-label": "\u0110\u1ED3 th\u1ECB 2D",
6656
+ "data-testid": "graph-editor-panel",
6657
+ "data-stamp-area": "true",
6658
+ "data-mobile-editor": isMobile ? "true" : void 0,
6659
+ style: wrapperStyle,
6660
+ className: [
6661
+ isDark ? "theme--dark " : "",
6662
+ "flex flex-col overflow-hidden bg-white",
6663
+ 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"
6664
+ ].join(" "),
6665
+ children: [
6666
+ /* @__PURE__ */ 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: [
6667
+ isMobile && /* @__PURE__ */ jsx(
6668
+ "button",
6669
+ {
6670
+ type: "button",
6671
+ onClick: props.onOpenDrawer,
6672
+ "aria-label": "M\u1EDF b\u1EA3ng \u0111\u1EA1i s\u1ED1",
6673
+ "data-testid": "graph-drawer-toggle",
6674
+ className: "-ml-1 inline-flex h-10 w-10 items-center justify-center rounded transition hover:bg-white/15",
6675
+ children: /* @__PURE__ */ jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
6676
+ /* @__PURE__ */ jsx("line", { x1: "4", y1: "6", x2: "20", y2: "6" }),
6677
+ /* @__PURE__ */ jsx("line", { x1: "4", y1: "12", x2: "20", y2: "12" }),
6678
+ /* @__PURE__ */ jsx("line", { x1: "4", y1: "18", x2: "20", y2: "18" })
6679
+ ] })
6680
+ }
6681
+ ),
6682
+ /* @__PURE__ */ jsxs("h3", { className: "flex flex-1 items-center gap-2 text-sm font-semibold", children: [
6683
+ /* @__PURE__ */ jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
6684
+ /* @__PURE__ */ jsx("path", { d: "M3 21 V3" }),
6685
+ /* @__PURE__ */ jsx("path", { d: "M3 21 H21" }),
6686
+ /* @__PURE__ */ jsx("path", { d: "M5 19 C8 5, 14 5, 19 17" })
6687
+ ] }),
6688
+ "\u0110\u1ED3 th\u1ECB 2D"
6689
+ ] }),
6690
+ isMobile && /* @__PURE__ */ jsx(
6691
+ "button",
6692
+ {
6693
+ type: "button",
6694
+ onClick: handleInsert,
6695
+ disabled: !hasContent,
6696
+ "data-testid": "graph-insert-btn-mobile",
6697
+ className: "rounded bg-white/15 px-3 py-1.5 text-xs font-semibold transition hover:bg-white/25 disabled:opacity-50",
6698
+ children: "Ch\xE8n"
6699
+ }
6700
+ ),
6701
+ /* @__PURE__ */ jsx(
6702
+ "button",
6703
+ {
6704
+ onClick: props.onClose,
6705
+ "aria-label": "\u0110\xF3ng",
6706
+ className: "inline-flex h-9 w-9 items-center justify-center rounded transition hover:bg-white/15",
6707
+ children: /* @__PURE__ */ jsxs("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
6708
+ /* @__PURE__ */ jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
6709
+ /* @__PURE__ */ jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
6710
+ ] })
6711
+ }
6712
+ )
6713
+ ] }),
6714
+ /* @__PURE__ */ jsx("div", { className: "min-h-0 flex-1", children: /* @__PURE__ */ jsx(
6715
+ MiniBoard,
6716
+ {
6717
+ graph,
6718
+ activeTool: tool,
6719
+ isDark,
6720
+ onBoardEvent
6721
+ }
6722
+ ) }),
6723
+ !isMobile && /* @__PURE__ */ jsxs("footer", { className: "flex items-center justify-between border-t border-slate-200 bg-slate-50 px-3 py-2", children: [
6724
+ /* @__PURE__ */ 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." }),
6725
+ /* @__PURE__ */ jsxs("div", { className: "flex gap-2", children: [
6726
+ /* @__PURE__ */ jsx(
6727
+ "button",
6728
+ {
6729
+ onClick: props.onClose,
6730
+ className: "rounded border border-slate-300 bg-white px-3 py-1 text-xs font-medium text-slate-700 transition hover:bg-slate-100",
6731
+ children: "Hu\u1EF7"
6732
+ }
6733
+ ),
6734
+ /* @__PURE__ */ jsx(
6735
+ "button",
6736
+ {
6737
+ onClick: handleInsert,
6738
+ disabled: !hasContent,
6739
+ "data-testid": "graph-insert-btn",
6740
+ className: "rounded bg-orange-600 px-3 py-1 text-xs font-medium text-white transition hover:bg-orange-700 disabled:opacity-50",
6741
+ children: "Ch\xE8n"
6742
+ }
6743
+ )
6744
+ ] })
6745
+ ] })
6746
+ ]
6747
+ }
6748
+ );
6749
+ });
6750
+ function isGraph2DCustomData(data) {
6751
+ if (!data || typeof data !== "object") return false;
6752
+ const d = data;
6753
+ return d.kind === "graph2d" && d.version === 1 && typeof d.jsonState === "string";
4547
6754
  }
4548
- var Geometry3DStampHost = forwardRef(
4549
- function Geometry3DStampHost2({ api, editingElement, onClose, isDark }, ref) {
4550
- const editorRef = useRef(null);
4551
- const initial = useMemo(
4552
- () => parseInitial(editingElement),
4553
- [editingElement]
6755
+ var INITIAL_GRAPH_STATE = {
6756
+ tool: "move",
6757
+ showAxis: true,
6758
+ showGrid: true,
6759
+ canUndo: false
6760
+ };
6761
+ var Graph2DStampHost = forwardRef(
6762
+ function Graph2DStampHost2({ api, editingElement, onClose, isDark }, ref) {
6763
+ const panelRef = useRef(null);
6764
+ const [graphUIState, setGraphUIState] = useState(INITIAL_GRAPH_STATE);
6765
+ const { isMobile } = useIsMobile();
6766
+ const [drawerOpen, setDrawerOpen] = useState(false);
6767
+ const initialState = useMemo(() => {
6768
+ if (!editingElement) return null;
6769
+ if (!isGraph2DCustomData(editingElement.customData)) return null;
6770
+ return parseSerializedGraph(editingElement.customData.jsonState);
6771
+ }, [editingElement]);
6772
+ const [graphSnapshot, setGraphSnapshot] = useState(
6773
+ initialState ?? EMPTY_GRAPH
4554
6774
  );
6775
+ const [errorsSnapshot, setErrorsSnapshot] = useState({});
4555
6776
  const handleInsert = useCallback(
4556
- async (jsonState, svgString, width, height) => {
6777
+ async (jsonState, svgString) => {
4557
6778
  if (!api) return;
4558
- await insertStampImage(api, {
4559
- svgString,
4560
- makeCustomData: () => ({
4561
- kind: "geometry3d",
4562
- version: 1,
4563
- jsonState,
4564
- svgWidth: width,
4565
- svgHeight: height
4566
- }),
4567
- editingElementId: editingElement?.id ?? null
4568
- });
6779
+ try {
6780
+ await insertStampImage(api, {
6781
+ svgString,
6782
+ makeCustomData: (width, height) => ({
6783
+ kind: "graph2d",
6784
+ version: 1,
6785
+ jsonState,
6786
+ svgWidth: width,
6787
+ svgHeight: height
6788
+ }),
6789
+ editingElementId: editingElement?.id ?? null
6790
+ });
6791
+ } catch (err) {
6792
+ console.error("Graph2D insert failed:", err);
6793
+ }
4569
6794
  onClose();
4570
6795
  },
4571
- [api, editingElement, onClose]
6796
+ [api, editingElement?.id, onClose]
4572
6797
  );
4573
6798
  useImperativeHandle(
4574
6799
  ref,
4575
6800
  () => ({
4576
- tryInsert: () => editorRef.current?.tryInsert() ?? false,
4577
- hasContent: () => editorRef.current?.hasContent() ?? false
6801
+ tryInsert: () => panelRef.current?.insert() ?? false,
6802
+ hasContent: () => panelRef.current?.hasContent() ?? false
4578
6803
  }),
4579
6804
  []
4580
6805
  );
4581
- return /* @__PURE__ */ jsx(
4582
- EditorPanel,
4583
- {
4584
- ref: editorRef,
4585
- isDark,
4586
- initial,
4587
- onInsert: handleInsert,
4588
- onClose
4589
- }
4590
- );
6806
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
6807
+ /* @__PURE__ */ jsx(
6808
+ GraphLeftPanel,
6809
+ {
6810
+ activeTool: graphUIState.tool,
6811
+ onToolChange: (t) => panelRef.current?.setTool(t),
6812
+ showAxis: graphUIState.showAxis,
6813
+ showGrid: graphUIState.showGrid,
6814
+ onShowAxisChange: (b) => panelRef.current?.setShowAxis(b),
6815
+ onShowGridChange: (b) => panelRef.current?.setShowGrid(b),
6816
+ onResetView: () => panelRef.current?.resetView(),
6817
+ onUndo: () => panelRef.current?.undo(),
6818
+ canUndo: graphUIState.canUndo,
6819
+ onClose,
6820
+ isDark,
6821
+ isMobile,
6822
+ drawerOpen,
6823
+ onDrawerClose: () => setDrawerOpen(false),
6824
+ graph: graphSnapshot,
6825
+ errors: errorsSnapshot,
6826
+ onAddFunctionDraft: () => {
6827
+ const result = panelRef.current?.addFunction("x");
6828
+ if (result && !result.ok) console.warn("addFunction failed:", result.error);
6829
+ },
6830
+ onCommitFunctionExpr: (id, expr) => panelRef.current?.commitFunctionExpression(id, expr),
6831
+ onToggleFunctionVisible: (id) => panelRef.current?.toggleFunctionVisible(id),
6832
+ onRemoveFunction: (id) => panelRef.current?.removeFunction(id),
6833
+ onParameterChange: (name, v) => panelRef.current?.setParameter(name, v),
6834
+ onParameterRangeChange: (name, min, max, step) => panelRef.current?.setParameterRange(name, min, max, step),
6835
+ onRemoveParameter: (name) => panelRef.current?.removeParameter(name)
6836
+ }
6837
+ ),
6838
+ /* @__PURE__ */ jsx(
6839
+ GraphEditorPanel,
6840
+ {
6841
+ ref: panelRef,
6842
+ initialState,
6843
+ onInsert: handleInsert,
6844
+ onClose,
6845
+ onStateChange: setGraphUIState,
6846
+ onGraphChange: setGraphSnapshot,
6847
+ onErrorsChange: setErrorsSnapshot,
6848
+ withLeftPanel: !isMobile,
6849
+ isDark,
6850
+ isMobile,
6851
+ onOpenDrawer: () => setDrawerOpen(true)
6852
+ }
6853
+ )
6854
+ ] });
4591
6855
  }
4592
6856
  );
4593
- var Geometry3DIcon = /* @__PURE__ */ jsxs(
6857
+ var Graph2DIcon = /* @__PURE__ */ jsxs(
4594
6858
  "svg",
4595
6859
  {
4596
6860
  width: "20",
@@ -4603,47 +6867,45 @@ var Geometry3DIcon = /* @__PURE__ */ jsxs(
4603
6867
  strokeLinejoin: "round",
4604
6868
  "aria-hidden": "true",
4605
6869
  children: [
4606
- /* @__PURE__ */ jsx("path", { d: "M12 3 L20 8 L20 16 L12 21 L4 16 L4 8 Z" }),
4607
- /* @__PURE__ */ jsx("path", { d: "M12 3 L12 21 M4 8 L12 12 L20 8 M4 16 L12 12 L20 16" })
6870
+ /* @__PURE__ */ jsx("path", { d: "M3 21 V3" }),
6871
+ /* @__PURE__ */ jsx("path", { d: "M3 21 H21" }),
6872
+ /* @__PURE__ */ jsx("path", { d: "M5 19 C8 5, 14 5, 19 17" })
4608
6873
  ]
4609
6874
  }
4610
6875
  );
4611
- var geometry3dStamp = {
4612
- kind: "geometry3d",
4613
- shortcutKey: "d",
4614
- toolbarLabel: "D",
4615
- toolbarTitle: "H\xECnh 3D (D)",
4616
- toolbarIcon: Geometry3DIcon,
4617
- toolbarTestId: "stamp-toolbar-geometry3d",
4618
- matchesCustomData: isGeometry3DCustomData,
6876
+ var graph2dStamp = {
6877
+ kind: "graph2d",
6878
+ shortcutKey: "h",
6879
+ toolbarLabel: "H",
6880
+ toolbarTitle: "Ch\xE8n \u0111\u1ED3 th\u1ECB 2D (H)",
6881
+ toolbarIcon: Graph2DIcon,
6882
+ toolbarTestId: "stamp-toolbar-graph2d",
6883
+ matchesCustomData: isGraph2DCustomData,
4619
6884
  async renderSvgFromCustomData(data) {
4620
- if (!isGeometry3DCustomData(data)) {
4621
- throw new Error("geometry3dStamp.renderSvgFromCustomData: customData kh\xF4ng ph\u1EA3i geometry3d");
6885
+ if (!isGraph2DCustomData(data)) {
6886
+ throw new Error("graph2dStamp.renderSvgFromCustomData: customData kh\xF4ng ph\u1EA3i graph2d");
4622
6887
  }
4623
- const { svgString } = await renderGeometry3DSvgFromState(data.jsonState);
4624
- return svgString;
6888
+ return renderGraph2dSvgFromState(data.jsonState);
4625
6889
  },
4626
- restoreFileFromCustomData: async (element) => {
6890
+ async restoreFileFromCustomData(element) {
4627
6891
  const data = element.customData;
4628
6892
  const fileId = element.fileId;
4629
6893
  if (!data || !fileId) return null;
4630
- if (!isGeometry3DCustomData(data)) return null;
4631
- try {
4632
- const { svgString } = await renderGeometry3DSvgFromState(data.jsonState);
4633
- const dataURL = `data:image/svg+xml;base64,${typeof btoa !== "undefined" ? btoa(unescape(encodeURIComponent(svgString))) : Buffer.from(svgString).toString("base64")}`;
4634
- return { fileId, dataURL, mimeType: "image/svg+xml" };
4635
- } catch {
4636
- return null;
4637
- }
6894
+ if (!isGraph2DCustomData(data)) return null;
6895
+ const svgString = await renderGraph2dSvgFromState(data.jsonState);
6896
+ const utf8 = unescape(encodeURIComponent(svgString));
6897
+ const dataURL = "data:image/svg+xml;base64," + (typeof btoa !== "undefined" ? btoa(utf8) : Buffer.from(utf8).toString("base64"));
6898
+ return { fileId, dataURL, mimeType: "image/svg+xml" };
4638
6899
  },
4639
- Host: Geometry3DStampHost
6900
+ Host: Graph2DStampHost
4640
6901
  };
4641
6902
 
4642
6903
  // src/stamps/shared/registry.ts
4643
6904
  var DEFAULT_STAMPS = Object.freeze([
4644
6905
  geometryStamp,
4645
6906
  latexStamp,
4646
- geometry3dStamp
6907
+ geometry3dStamp,
6908
+ graph2dStamp
4647
6909
  ]);
4648
6910
  function findStampForCustomData(data, stamps = DEFAULT_STAMPS) {
4649
6911
  for (const s of stamps) {
@@ -4654,36 +6916,90 @@ function findStampForCustomData(data, stamps = DEFAULT_STAMPS) {
4654
6916
  function isStampElement(element, stamps = DEFAULT_STAMPS) {
4655
6917
  return findStampForCustomData(element.customData, stamps) !== null;
4656
6918
  }
4657
- var WRAPPER_ID = "stamp-toolbar-portal-wrapper";
6919
+ var TOOLBAR_WRAPPER_ID = "stamp-toolbar-portal-wrapper";
6920
+ var MENU_WRAPPER_ID = "stamp-menu-portal-wrapper";
4658
6921
  function ToolbarInjector({
4659
6922
  enabled,
4660
6923
  activeStampKind,
4661
6924
  onToggle,
4662
6925
  stamps = DEFAULT_STAMPS
4663
6926
  }) {
4664
- const [mountNode, setMountNode] = useState(null);
6927
+ const [isMobile, setIsMobile] = useState(false);
6928
+ const [toolbarMount, setToolbarMount] = useState(null);
6929
+ const [menuMount, setMenuMount] = useState(null);
6930
+ const isMobileRef = useRef(false);
6931
+ const toolbarMountRef = useRef(null);
6932
+ const menuMountRef = useRef(null);
4665
6933
  useEffect(() => {
4666
6934
  if (!enabled) {
4667
- setMountNode(null);
6935
+ if (isMobileRef.current !== false) {
6936
+ isMobileRef.current = false;
6937
+ setIsMobile(false);
6938
+ }
6939
+ return;
6940
+ }
6941
+ let cancelled = false;
6942
+ let observer = null;
6943
+ let timer = null;
6944
+ let attempts = 0;
6945
+ const apply = (next) => {
6946
+ if (cancelled || isMobileRef.current === next) return;
6947
+ isMobileRef.current = next;
6948
+ queueMicrotask(() => {
6949
+ if (!cancelled) setIsMobile(next);
6950
+ });
6951
+ };
6952
+ const attach = () => {
6953
+ if (cancelled) return;
6954
+ const root = document.querySelector(".excalidraw");
6955
+ if (!root) {
6956
+ if (attempts++ < 20) timer = setTimeout(attach, 100);
6957
+ return;
6958
+ }
6959
+ apply(root.classList.contains("excalidraw--mobile"));
6960
+ observer = new MutationObserver(() => {
6961
+ apply(root.classList.contains("excalidraw--mobile"));
6962
+ });
6963
+ observer.observe(root, { attributes: true, attributeFilter: ["class"] });
6964
+ };
6965
+ attach();
6966
+ return () => {
6967
+ cancelled = true;
6968
+ if (timer) clearTimeout(timer);
6969
+ observer?.disconnect();
6970
+ };
6971
+ }, [enabled]);
6972
+ useEffect(() => {
6973
+ if (!enabled || isMobile) {
6974
+ if (toolbarMountRef.current !== null) {
6975
+ toolbarMountRef.current = null;
6976
+ setToolbarMount(null);
6977
+ }
6978
+ document.getElementById(TOOLBAR_WRAPPER_ID)?.remove();
4668
6979
  return;
4669
6980
  }
4670
6981
  let cancelled = false;
4671
6982
  let attempts = 0;
4672
6983
  let observer = null;
4673
6984
  let timer = null;
6985
+ const apply = (next) => {
6986
+ if (cancelled || toolbarMountRef.current === next) return;
6987
+ toolbarMountRef.current = next;
6988
+ queueMicrotask(() => {
6989
+ if (!cancelled) setToolbarMount(next);
6990
+ });
6991
+ };
4674
6992
  const tryMount = () => {
4675
6993
  if (cancelled) return;
4676
6994
  const container = document.querySelector(".excalidraw .App-toolbar .Stack_horizontal") ?? document.querySelector(".App-toolbar .Stack_horizontal");
4677
6995
  if (!container) {
4678
- if (attempts++ < 20) {
4679
- timer = setTimeout(tryMount, 100);
4680
- }
6996
+ if (attempts++ < 20) timer = setTimeout(tryMount, 100);
4681
6997
  return;
4682
6998
  }
4683
- let wrapper = container.querySelector("#" + WRAPPER_ID);
6999
+ let wrapper = container.querySelector("#" + TOOLBAR_WRAPPER_ID);
4684
7000
  if (!wrapper) {
4685
7001
  wrapper = document.createElement("div");
4686
- wrapper.id = WRAPPER_ID;
7002
+ wrapper.id = TOOLBAR_WRAPPER_ID;
4687
7003
  wrapper.className = "Stamp-toolbar-injector";
4688
7004
  wrapper.setAttribute("data-stamp-area", "true");
4689
7005
  wrapper.style.display = "inline-flex";
@@ -4699,13 +7015,13 @@ function ToolbarInjector({
4699
7015
  container.appendChild(wrapper);
4700
7016
  }
4701
7017
  }
4702
- setMountNode(wrapper);
7018
+ apply(wrapper);
4703
7019
  };
4704
7020
  tryMount();
4705
7021
  const root = document.querySelector(".excalidraw") ?? document.body;
4706
7022
  observer = new MutationObserver(() => {
4707
7023
  if (cancelled) return;
4708
- const stillThere = document.getElementById(WRAPPER_ID);
7024
+ const stillThere = document.getElementById(TOOLBAR_WRAPPER_ID);
4709
7025
  if (!stillThere) {
4710
7026
  attempts = 0;
4711
7027
  tryMount();
@@ -4716,11 +7032,73 @@ function ToolbarInjector({
4716
7032
  cancelled = true;
4717
7033
  if (timer) clearTimeout(timer);
4718
7034
  observer?.disconnect();
4719
- document.getElementById(WRAPPER_ID)?.remove();
7035
+ document.getElementById(TOOLBAR_WRAPPER_ID)?.remove();
4720
7036
  };
4721
- }, [enabled]);
4722
- if (!enabled || !mountNode) return null;
4723
- return createPortal(
7037
+ }, [enabled, isMobile]);
7038
+ useEffect(() => {
7039
+ if (!enabled || !isMobile) {
7040
+ if (menuMountRef.current !== null) {
7041
+ menuMountRef.current = null;
7042
+ setMenuMount(null);
7043
+ }
7044
+ document.getElementById(MENU_WRAPPER_ID)?.remove();
7045
+ return;
7046
+ }
7047
+ let cancelled = false;
7048
+ let observer = null;
7049
+ let rafId = null;
7050
+ const apply = (next) => {
7051
+ if (cancelled || menuMountRef.current === next) return;
7052
+ menuMountRef.current = next;
7053
+ queueMicrotask(() => {
7054
+ if (!cancelled) setMenuMount(next);
7055
+ });
7056
+ };
7057
+ const findMenu = () => {
7058
+ if (cancelled) return;
7059
+ const container = document.querySelector(
7060
+ ".dropdown-menu--mobile .dropdown-menu-container"
7061
+ );
7062
+ if (!container) {
7063
+ apply(null);
7064
+ return;
7065
+ }
7066
+ let wrapper = container.querySelector("#" + MENU_WRAPPER_ID);
7067
+ if (!wrapper) {
7068
+ wrapper = document.createElement("div");
7069
+ wrapper.id = MENU_WRAPPER_ID;
7070
+ wrapper.setAttribute("data-stamp-menu", "true");
7071
+ wrapper.style.display = "contents";
7072
+ container.insertBefore(wrapper, container.firstChild);
7073
+ }
7074
+ apply(wrapper);
7075
+ };
7076
+ const schedule = () => {
7077
+ if (rafId != null) return;
7078
+ rafId = requestAnimationFrame(() => {
7079
+ rafId = null;
7080
+ findMenu();
7081
+ });
7082
+ };
7083
+ findMenu();
7084
+ const root = document.querySelector(".excalidraw") ?? document.body;
7085
+ observer = new MutationObserver(schedule);
7086
+ observer.observe(root, { childList: true, subtree: true });
7087
+ return () => {
7088
+ cancelled = true;
7089
+ if (rafId != null) cancelAnimationFrame(rafId);
7090
+ observer?.disconnect();
7091
+ document.getElementById(MENU_WRAPPER_ID)?.remove();
7092
+ };
7093
+ }, [enabled, isMobile]);
7094
+ if (!enabled) return null;
7095
+ const closeMobileMenu = () => {
7096
+ const trigger = document.querySelector(
7097
+ ".App-toolbar__extra-tools-trigger"
7098
+ );
7099
+ trigger?.click();
7100
+ };
7101
+ const desktopButtons = !isMobile && toolbarMount ? createPortal(
4724
7102
  /* @__PURE__ */ jsx(Fragment, { children: stamps.map((stamp) => /* @__PURE__ */ jsx(
4725
7103
  StampToolButton,
4726
7104
  {
@@ -4733,8 +7111,42 @@ function ToolbarInjector({
4733
7111
  },
4734
7112
  stamp.kind
4735
7113
  )) }),
4736
- mountNode
4737
- );
7114
+ toolbarMount
7115
+ ) : null;
7116
+ const mobileMenuItems = isMobile && menuMount ? createPortal(
7117
+ /* @__PURE__ */ jsxs(Fragment, { children: [
7118
+ stamps.map((stamp) => /* @__PURE__ */ jsx(
7119
+ StampMenuItem,
7120
+ {
7121
+ icon: stamp.toolbarIcon,
7122
+ label: stamp.toolbarTitle,
7123
+ active: activeStampKind === stamp.kind,
7124
+ onClick: () => {
7125
+ onToggle(stamp.kind);
7126
+ closeMobileMenu();
7127
+ },
7128
+ dataTestId: stamp.toolbarTestId
7129
+ },
7130
+ stamp.kind
7131
+ )),
7132
+ /* @__PURE__ */ jsx(
7133
+ "div",
7134
+ {
7135
+ "aria-hidden": "true",
7136
+ style: {
7137
+ height: 1,
7138
+ background: "var(--default-border-color, rgba(0,0,0,0.08))",
7139
+ margin: "6px 4px"
7140
+ }
7141
+ }
7142
+ )
7143
+ ] }),
7144
+ menuMount
7145
+ ) : null;
7146
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
7147
+ desktopButtons,
7148
+ mobileMenuItems
7149
+ ] });
4738
7150
  }
4739
7151
  function StampToolButton({ icon, keybind, label, active, onClick, dataTestId }) {
4740
7152
  return /* @__PURE__ */ jsxs(
@@ -4799,6 +7211,54 @@ function StampToolButton({ icon, keybind, label, active, onClick, dataTestId })
4799
7211
  }
4800
7212
  );
4801
7213
  }
7214
+ function StampMenuItem({ icon, label, active, onClick, dataTestId }) {
7215
+ const className = [
7216
+ "dropdown-menu-item",
7217
+ "dropdown-menu-item-base",
7218
+ active ? "dropdown-menu-item--selected" : ""
7219
+ ].filter(Boolean).join(" ");
7220
+ return /* @__PURE__ */ jsxs(
7221
+ "button",
7222
+ {
7223
+ type: "button",
7224
+ onClick,
7225
+ "aria-label": label,
7226
+ "aria-pressed": active,
7227
+ "data-testid": dataTestId,
7228
+ className,
7229
+ style: {
7230
+ display: "flex",
7231
+ alignItems: "center",
7232
+ columnGap: "0.625rem",
7233
+ width: "100%",
7234
+ boxSizing: "border-box",
7235
+ background: "transparent",
7236
+ border: "1px solid transparent",
7237
+ cursor: "pointer",
7238
+ fontFamily: "inherit",
7239
+ fontSize: "0.875rem",
7240
+ color: "var(--color-on-surface)"
7241
+ },
7242
+ children: [
7243
+ /* @__PURE__ */ jsx(
7244
+ "span",
7245
+ {
7246
+ "aria-hidden": "true",
7247
+ style: {
7248
+ display: "inline-flex",
7249
+ alignItems: "center",
7250
+ justifyContent: "center",
7251
+ width: "1rem",
7252
+ height: "1rem"
7253
+ },
7254
+ children: icon
7255
+ }
7256
+ ),
7257
+ /* @__PURE__ */ jsx("span", { children: label })
7258
+ ]
7259
+ }
7260
+ );
7261
+ }
4802
7262
  function isEditableTarget(t) {
4803
7263
  if (!t || !(t instanceof HTMLElement)) return false;
4804
7264
  if (t.isContentEditable) return true;
@@ -5098,7 +7558,7 @@ async function pruneFiles(storageKey, keepIds) {
5098
7558
  }
5099
7559
  }
5100
7560
  var Excalidraw = dynamic(
5101
- async () => (await import('./ExcalidrawWithMenus-KBLDWPM2.mjs')).ExcalidrawWithMenus,
7561
+ async () => (await import('./ExcalidrawWithMenus-EAVPOPJZ.mjs')).ExcalidrawWithMenus,
5102
7562
  {
5103
7563
  ssr: false,
5104
7564
  loading: () => /* @__PURE__ */ jsx("div", { className: "flex h-full items-center justify-center text-sm text-gray-500", children: "\u0110ang t\u1EA3i b\u1EA3ng\u2026" })
@@ -5116,7 +7576,9 @@ function Whiteboard({
5116
7576
  stamps = DEFAULT_STAMPS
5117
7577
  }) {
5118
7578
  const [api, setApi] = useState(null);
7579
+ const apiRef = useRef(null);
5119
7580
  const [isDarkTheme, setIsDarkTheme] = useState(false);
7581
+ const isDarkThemeRef = useRef(false);
5120
7582
  const knownFileIdsRef = useRef(/* @__PURE__ */ new Set());
5121
7583
  const lastSceneHashRef = useRef("");
5122
7584
  const sceneThrottleRef = useRef(null);
@@ -5177,7 +7639,10 @@ function Whiteboard({
5177
7639
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
5178
7640
  (elements, appState, files) => {
5179
7641
  const nextDark = appState?.theme === "dark";
5180
- setIsDarkTheme((prev) => prev === nextDark ? prev : nextDark);
7642
+ if (isDarkThemeRef.current !== nextDark) {
7643
+ isDarkThemeRef.current = nextDark;
7644
+ queueMicrotask(() => setIsDarkTheme(nextDark));
7645
+ }
5181
7646
  if (readOnly) return;
5182
7647
  latestSceneRef.current = { elements, appState };
5183
7648
  const cropId = appState?.croppingElementId;
@@ -5187,12 +7652,17 @@ function Whiteboard({
5187
7652
  const stamp = findStampForCustomData(el.customData, stamps);
5188
7653
  if (stamp) {
5189
7654
  handledCropIdRef.current = cropId;
5190
- api.updateScene({
5191
- appState: { ...appState, croppingElementId: null, selectedElementIds: {} }
5192
- });
5193
- openStamp(stamp.kind, {
5194
- id: el.id,
5195
- customData: el.customData
7655
+ const elId = el.id;
7656
+ const elCustom = el.customData;
7657
+ const stampKind = stamp.kind;
7658
+ queueMicrotask(() => {
7659
+ try {
7660
+ api.updateScene({
7661
+ appState: { croppingElementId: null, selectedElementIds: {} }
7662
+ });
7663
+ } catch {
7664
+ }
7665
+ openStamp(stampKind, { id: elId, customData: elCustom });
5196
7666
  });
5197
7667
  return;
5198
7668
  }
@@ -5443,8 +7913,12 @@ function Whiteboard({
5443
7913
  Excalidraw,
5444
7914
  {
5445
7915
  excalidrawAPI: (a) => {
5446
- setApi(a);
5447
- onApi?.(a);
7916
+ if (apiRef.current === a) return;
7917
+ apiRef.current = a;
7918
+ queueMicrotask(() => {
7919
+ setApi(a);
7920
+ onApi?.(a);
7921
+ });
5448
7922
  },
5449
7923
  langCode,
5450
7924
  viewModeEnabled: readOnly,
@@ -5481,6 +7955,6 @@ function Whiteboard({
5481
7955
  ] });
5482
7956
  }
5483
7957
 
5484
- export { DEFAULT_STAMPS, Whiteboard, findStampForCustomData, geometry3dStamp, geometryStamp, isGeometry3DCustomData, isGeometryCustomData, isLatexCustomData, isStampElement, latexStamp, pickSyncableAppState, restoreMissingStampFiles };
7958
+ export { DEFAULT_STAMPS, Whiteboard, findStampForCustomData, geometry3dStamp, geometryStamp, graph2dStamp, isGeometry3DCustomData, isGeometryCustomData, isGraph2DCustomData, isLatexCustomData, isStampElement, latexStamp, pickSyncableAppState, restoreMissingStampFiles };
5485
7959
  //# sourceMappingURL=index.mjs.map
5486
7960
  //# sourceMappingURL=index.mjs.map