@xom11/whiteboard 0.6.1 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,13 +1,12 @@
1
1
  "use client";
2
+ import './index.css';
3
+ import { __require } from './chunk-BJTO5JO5.mjs';
2
4
  import dynamic from 'next/dynamic';
3
- import { forwardRef, useRef, useState, useEffect, useCallback, useImperativeHandle, useMemo, useId } from 'react';
5
+ import { forwardRef, useRef, useState, useEffect, useCallback, useImperativeHandle, useMemo, useId, useLayoutEffect } from 'react';
4
6
  import { createPortal } from 'react-dom';
5
7
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
6
- import JXG from 'jsxgraph';
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();
@@ -637,9 +652,9 @@ function handleMove(ctx, e) {
637
652
  if (!ctx.boardRef.current || !ctx.phantomRef.current) return;
638
653
  try {
639
654
  const coords = ctx.boardRef.current.getUsrCoordsOfMouse(e);
640
- const JXG3 = ctx.jxgRef.current;
641
- if (!JXG3) return;
642
- ctx.phantomRef.current.setPositionDirectly(JXG3.COORDS_BY_USER, [coords[0], coords[1]]);
655
+ const JXG = ctx.jxgRef.current;
656
+ if (!JXG) return;
657
+ ctx.phantomRef.current.setPositionDirectly(JXG.COORDS_BY_USER, [coords[0], coords[1]]);
643
658
  ctx.boardRef.current.update();
644
659
  } catch {
645
660
  }
@@ -1446,11 +1461,11 @@ var JSXGraphMiniBoard = ({ onReady, initialState, isDark }) => {
1446
1461
  if (typeof window === "undefined" || !containerRef.current) return;
1447
1462
  let cancelled = false;
1448
1463
  (async () => {
1449
- const JXG3 = (await import('jsxgraph')).default;
1464
+ const JXG = (await import('jsxgraph')).default;
1450
1465
  if (cancelled || !containerRef.current) return;
1451
- jxgRef.current = JXG3;
1466
+ jxgRef.current = JXG;
1452
1467
  try {
1453
- const opts = JXG3.Options;
1468
+ const opts = JXG.Options;
1454
1469
  if (opts) {
1455
1470
  opts.text = opts.text || {};
1456
1471
  opts.text.display = "internal";
@@ -1464,7 +1479,7 @@ var JSXGraphMiniBoard = ({ onReady, initialState, isDark }) => {
1464
1479
  }
1465
1480
  } catch {
1466
1481
  }
1467
- const board = JXG3.JSXGraph.initBoard(containerId, {
1482
+ const board = JXG.JSXGraph.initBoard(containerId, {
1468
1483
  boundingbox: initialState?.bbox ?? [-10, 10, 10, -10],
1469
1484
  axis: false,
1470
1485
  // We manage axis manually via toggle for clean default
@@ -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 = {}) {
@@ -2096,9 +2420,9 @@ function renderGeometryToSvg(boardContainer) {
2096
2420
  async function renderGeometrySvgFromState(jsonState) {
2097
2421
  const parsed = JSON.parse(jsonState);
2098
2422
  const palette = paletteFor(false);
2099
- const JXG3 = (await import('jsxgraph')).default;
2423
+ const JXG = (await import('jsxgraph')).default;
2100
2424
  try {
2101
- const opts = JXG3.Options;
2425
+ const opts = JXG.Options;
2102
2426
  if (opts) {
2103
2427
  opts.text = opts.text || {};
2104
2428
  opts.text.display = "internal";
@@ -2123,7 +2447,7 @@ async function renderGeometrySvgFromState(jsonState) {
2123
2447
  document.body.appendChild(container);
2124
2448
  let board = null;
2125
2449
  try {
2126
- board = JXG3.JSXGraph.initBoard(containerId, {
2450
+ board = JXG.JSXGraph.initBoard(containerId, {
2127
2451
  boundingbox: parsed.bbox,
2128
2452
  axis: !!parsed.showAxis,
2129
2453
  grid: !!parsed.showGrid,
@@ -2136,7 +2460,7 @@ async function renderGeometrySvgFromState(jsonState) {
2136
2460
  return renderGeometryToSvg(container);
2137
2461
  } finally {
2138
2462
  try {
2139
- if (board) JXG3.JSXGraph.freeBoard(board);
2463
+ if (board) JXG.JSXGraph.freeBoard(board);
2140
2464
  } catch {
2141
2465
  }
2142
2466
  if (container.parentNode) container.parentNode.removeChild(container);
@@ -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
  ] });
@@ -3808,126 +4375,134 @@ var MiniBoard3D = forwardRef(function MiniBoard3D2({ isDark, initialState }, ref
3808
4375
  useEffect(() => {
3809
4376
  const div = containerRef.current;
3810
4377
  if (!div) return;
3811
- JXG.Options.text.display = "internal";
3812
- const board = JXG.JSXGraph.initBoard(div, {
3813
- boundingbox: [-6, 6, 6, -6],
3814
- axis: false,
3815
- showCopyright: false,
3816
- showNavigation: false,
3817
- renderer: "svg"
3818
- });
3819
- boardRef.current = board;
3820
- const initView = initialState?.view ?? DEFAULT_VIEW3D;
3821
- const baseAttrs = VIEW3D_ATTRS(isDark);
3822
- const view = board.create(
3823
- "view3d",
3824
- [
3825
- [-5, -5],
3826
- [10, 10],
4378
+ let cancelled = false;
4379
+ let JXG = null;
4380
+ let board = null;
4381
+ void (async () => {
4382
+ JXG = (await import('jsxgraph')).default;
4383
+ if (cancelled || !containerRef.current) return;
4384
+ JXG.Options.text.display = "internal";
4385
+ board = JXG.JSXGraph.initBoard(div, {
4386
+ boundingbox: [-6, 6, 6, -6],
4387
+ axis: false,
4388
+ showCopyright: false,
4389
+ showNavigation: false,
4390
+ renderer: "svg"
4391
+ });
4392
+ boardRef.current = board;
4393
+ const initView = initialState?.view ?? DEFAULT_VIEW3D;
4394
+ const baseAttrs = VIEW3D_ATTRS(isDark);
4395
+ const view = board.create(
4396
+ "view3d",
3827
4397
  [
3828
- [initView.bbox3D[0], initView.bbox3D[3]],
3829
- [initView.bbox3D[1], initView.bbox3D[4]],
3830
- [initView.bbox3D[2], initView.bbox3D[5]]
3831
- ]
3832
- ],
3833
- {
3834
- ...baseAttrs,
3835
- az: { ...baseAttrs.az, value: initView.azimuth },
3836
- el: { ...baseAttrs.el, value: initView.elevation }
3837
- }
3838
- );
3839
- viewRef.current = view;
3840
- let idCounter = 1;
3841
- const ctx = createHandlerContext({
3842
- view,
3843
- pushLog: (e) => {
3844
- logRef.current.push(e);
3845
- notify();
3846
- },
3847
- objMap: objMapRef.current,
3848
- nextId: () => `obj_${Date.now().toString(36)}_${(idCounter++).toString(36)}`,
3849
- isDark,
3850
- promptCoords: (label) => {
3851
- const raw = window.prompt(`${label}
4398
+ [-5, -5],
4399
+ [10, 10],
4400
+ [
4401
+ [initView.bbox3D[0], initView.bbox3D[3]],
4402
+ [initView.bbox3D[1], initView.bbox3D[4]],
4403
+ [initView.bbox3D[2], initView.bbox3D[5]]
4404
+ ]
4405
+ ],
4406
+ {
4407
+ ...baseAttrs,
4408
+ az: { ...baseAttrs.az, value: initView.azimuth },
4409
+ el: { ...baseAttrs.el, value: initView.elevation }
4410
+ }
4411
+ );
4412
+ viewRef.current = view;
4413
+ let idCounter = 1;
4414
+ const ctx = createHandlerContext({
4415
+ view,
4416
+ pushLog: (e) => {
4417
+ logRef.current.push(e);
4418
+ notify();
4419
+ },
4420
+ objMap: objMapRef.current,
4421
+ nextId: () => `obj_${Date.now().toString(36)}_${(idCounter++).toString(36)}`,
4422
+ isDark,
4423
+ promptCoords: (label) => {
4424
+ const raw = window.prompt(`${label}
3852
4425
  (\u0111\u1ECBnh d\u1EA1ng "x,y,z")`, "0,0,0");
3853
- if (!raw) return null;
3854
- const parts = raw.split(",").map((s) => Number(s.trim()));
3855
- if (parts.length !== 3 || parts.some((n) => !isFinite(n))) return null;
3856
- return { x: parts[0], y: parts[1], z: parts[2] };
3857
- },
3858
- promptNumber: (label) => {
3859
- const raw = window.prompt(label, "1");
3860
- if (raw == null) return null;
3861
- const n = Number(raw);
3862
- return isFinite(n) ? n : null;
3863
- },
3864
- promptText: (label) => {
3865
- const raw = window.prompt(label, "");
3866
- return raw == null ? null : raw;
3867
- },
3868
- notify
3869
- });
3870
- ctxRef.current = ctx;
3871
- function findExistingPointAt(clientX, clientY) {
3872
- const containerRect = div.getBoundingClientRect();
3873
- const localX = clientX - containerRect.left;
3874
- const localY = clientY - containerRect.top;
3875
- const PICK = 18;
3876
- const svg = div.querySelector("svg");
3877
- if (!svg) return void 0;
3878
- for (const [id, obj] of objMapRef.current) {
3879
- const entry = obj;
3880
- if (entry?.elType !== "point3d") continue;
3881
- const sc = entry.element2D?.coords?.scrCoords;
3882
- if (!sc || sc.length < 3) continue;
3883
- const dx = sc[1] - localX;
3884
- const dy = sc[2] - localY;
3885
- if (dx * dx + dy * dy <= PICK * PICK) return id;
3886
- }
3887
- return void 0;
3888
- }
3889
- const handlePointerDown = (e) => {
3890
- const tool = toolRef.current;
3891
- if (tool === "move") return;
3892
- const existingPointId = findExistingPointAt(e.clientX, e.clientY);
3893
- let x3 = 0;
3894
- let y3 = 0;
3895
- const z3 = 0;
3896
- try {
3897
- const board2d = boardRef.current;
3898
- if (board2d?.getUsrCoordsOfMouse) {
3899
- const uc = board2d.getUsrCoordsOfMouse(e);
3900
- if (Array.isArray(uc) && uc.length >= 2) {
3901
- x3 = uc[0];
3902
- y3 = uc[1];
3903
- }
4426
+ if (!raw) return null;
4427
+ const parts = raw.split(",").map((s) => Number(s.trim()));
4428
+ if (parts.length !== 3 || parts.some((n) => !isFinite(n))) return null;
4429
+ return { x: parts[0], y: parts[1], z: parts[2] };
4430
+ },
4431
+ promptNumber: (label) => {
4432
+ const raw = window.prompt(label, "1");
4433
+ if (raw == null) return null;
4434
+ const n = Number(raw);
4435
+ return isFinite(n) ? n : null;
4436
+ },
4437
+ promptText: (label) => {
4438
+ const raw = window.prompt(label, "");
4439
+ return raw == null ? null : raw;
4440
+ },
4441
+ notify
4442
+ });
4443
+ ctxRef.current = ctx;
4444
+ function findExistingPointAt(clientX, clientY) {
4445
+ const containerRect = div.getBoundingClientRect();
4446
+ const localX = clientX - containerRect.left;
4447
+ const localY = clientY - containerRect.top;
4448
+ const PICK = 18;
4449
+ const svg = div.querySelector("svg");
4450
+ if (!svg) return void 0;
4451
+ for (const [id, obj] of objMapRef.current) {
4452
+ const entry = obj;
4453
+ if (entry?.elType !== "point3d") continue;
4454
+ const sc = entry.element2D?.coords?.scrCoords;
4455
+ if (!sc || sc.length < 3) continue;
4456
+ const dx = sc[1] - localX;
4457
+ const dy = sc[2] - localY;
4458
+ if (dx * dx + dy * dy <= PICK * PICK) return id;
3904
4459
  }
3905
- } catch {
4460
+ return void 0;
3906
4461
  }
3907
- const hit = { x3, y3, z3, existingPointId };
3908
- handleToolStep(ctx, tool, hit);
3909
- };
3910
- const svgEl = div.querySelector("svg");
3911
- const targetEl = svgEl ?? div;
3912
- const handlePointerDownEv = (e) => handlePointerDown(e);
3913
- targetEl.addEventListener("pointerdown", handlePointerDownEv);
3914
- pointerHandlerRef.current = { el: targetEl, fn: handlePointerDownEv };
3915
- if (initialState?.elements?.length) {
3916
- const map = objMapRef.current;
3917
- for (const el of initialState.elements) {
3918
- const parents = el.parents.map(
3919
- (p2) => typeof p2 === "string" && p2.startsWith("@id:") ? map.get(p2.slice(4)) : p2
3920
- );
3921
- const obj = view.create(el.type, parents, {
3922
- ...el.attributes,
3923
- id: el.id,
3924
- name: el.label
3925
- });
3926
- map.set(el.id, obj);
3927
- logRef.current.push(el);
4462
+ const handlePointerDown = (e) => {
4463
+ const tool = toolRef.current;
4464
+ if (tool === "move") return;
4465
+ const existingPointId = findExistingPointAt(e.clientX, e.clientY);
4466
+ let x3 = 0;
4467
+ let y3 = 0;
4468
+ const z3 = 0;
4469
+ try {
4470
+ const board2d = boardRef.current;
4471
+ if (board2d?.getUsrCoordsOfMouse) {
4472
+ const uc = board2d.getUsrCoordsOfMouse(e);
4473
+ if (Array.isArray(uc) && uc.length >= 2) {
4474
+ x3 = uc[0];
4475
+ y3 = uc[1];
4476
+ }
4477
+ }
4478
+ } catch {
4479
+ }
4480
+ const hit = { x3, y3, z3, existingPointId };
4481
+ handleToolStep(ctx, tool, hit);
4482
+ };
4483
+ const svgEl = div.querySelector("svg");
4484
+ const targetEl = svgEl ?? div;
4485
+ const handlePointerDownEv = (e) => handlePointerDown(e);
4486
+ targetEl.addEventListener("pointerdown", handlePointerDownEv);
4487
+ pointerHandlerRef.current = { el: targetEl, fn: handlePointerDownEv };
4488
+ if (initialState?.elements?.length) {
4489
+ const map = objMapRef.current;
4490
+ for (const el of initialState.elements) {
4491
+ const parents = el.parents.map(
4492
+ (p2) => typeof p2 === "string" && p2.startsWith("@id:") ? map.get(p2.slice(4)) : p2
4493
+ );
4494
+ const obj = view.create(el.type, parents, {
4495
+ ...el.attributes,
4496
+ id: el.id,
4497
+ name: el.label
4498
+ });
4499
+ map.set(el.id, obj);
4500
+ logRef.current.push(el);
4501
+ }
3928
4502
  }
3929
- }
4503
+ })();
3930
4504
  return () => {
4505
+ cancelled = true;
3931
4506
  if (pointerHandlerRef.current) {
3932
4507
  pointerHandlerRef.current.el.removeEventListener(
3933
4508
  "pointerdown",
@@ -3936,7 +4511,7 @@ var MiniBoard3D = forwardRef(function MiniBoard3D2({ isDark, initialState }, ref
3936
4511
  pointerHandlerRef.current = null;
3937
4512
  }
3938
4513
  try {
3939
- JXG.JSXGraph.freeBoard(board);
4514
+ if (board && JXG) JXG.JSXGraph.freeBoard(board);
3940
4515
  } catch {
3941
4516
  }
3942
4517
  boardRef.current = null;
@@ -3953,7 +4528,25 @@ var MiniBoard3D = forwardRef(function MiniBoard3D2({ isDark, initialState }, ref
3953
4528
  toolRef.current = t;
3954
4529
  notify();
3955
4530
  },
3956
- 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
+ }),
3957
4550
  pushLog: (e) => {
3958
4551
  logRef.current.push(e);
3959
4552
  notify();
@@ -4029,6 +4622,138 @@ var MiniBoard3D = forwardRef(function MiniBoard3D2({ isDark, initialState }, ref
4029
4622
  }
4030
4623
  );
4031
4624
  });
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
+ });
4032
4757
 
4033
4758
  // src/stamps/geometry-3d/editor/tools.ts
4034
4759
  var GROUP_LABELS_3D = {
@@ -4038,6 +4763,18 @@ var GROUP_LABELS_3D = {
4038
4763
  curved: "Kh\u1ED1i cong",
4039
4764
  meta: "Kh\xE1c"
4040
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
+ }
4041
4778
  var TOOLS_3D = [
4042
4779
  { key: "move", label: "Di chuy\u1EC3n", group: "view", stepsRequired: 0 },
4043
4780
  { key: "point", label: "\u0110i\u1EC3m", group: "primitive", stepsRequired: 1, hint: "Nh\u1EADp (x, y, z)" },
@@ -4091,8 +4828,8 @@ var TOOLS_3D = [
4091
4828
  },
4092
4829
  { key: "label", label: "Nh\xE3n", group: "meta", stepsRequired: 1, hint: "G\u1EAFn v\xE0o \u0111i\u1EC3m" }
4093
4830
  ];
4094
- function ToolButton({ toolKey, label, hint, active, onClick, icon }) {
4095
- return /* @__PURE__ */ jsx(
4831
+ function ToolButton({ toolKey, label, hint, active, onClick, icon, badge }) {
4832
+ return /* @__PURE__ */ jsxs(
4096
4833
  "button",
4097
4834
  {
4098
4835
  type: "button",
@@ -4103,10 +4840,13 @@ function ToolButton({ toolKey, label, hint, active, onClick, icon }) {
4103
4840
  "data-active": active || void 0,
4104
4841
  "data-tool": toolKey,
4105
4842
  className: [
4106
- "flex h-8 items-center justify-center rounded-md transition",
4843
+ "relative flex h-8 items-center justify-center rounded-md transition",
4107
4844
  active ? "bg-blue-600 text-white shadow-sm" : "text-slate-700 hover:bg-slate-100 hover:text-slate-900"
4108
4845
  ].join(" "),
4109
- children: icon
4846
+ children: [
4847
+ icon,
4848
+ badge
4849
+ ]
4110
4850
  }
4111
4851
  );
4112
4852
  }
@@ -4158,7 +4898,10 @@ function Shell3({ title, icon, onClose, children, isDark }) {
4158
4898
  "aria-label": title,
4159
4899
  "data-testid": "geom3d-left-panel",
4160
4900
  "data-stamp-area": "true",
4161
- 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(""),
4162
4905
  children: [
4163
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: [
4164
4907
  /* @__PURE__ */ jsxs("h3", { className: "flex items-center gap-2 text-sm font-semibold text-slate-800", children: [
@@ -4171,10 +4914,7 @@ function Shell3({ title, icon, onClose, children, isDark }) {
4171
4914
  onClick: onClose,
4172
4915
  "aria-label": "\u0110\xF3ng",
4173
4916
  className: "rounded p-1 text-slate-500 transition hover:bg-slate-100 hover:text-slate-800",
4174
- children: /* @__PURE__ */ jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
4175
- /* @__PURE__ */ jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" }),
4176
- /* @__PURE__ */ jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" })
4177
- ] })
4917
+ children: /* @__PURE__ */ jsx(CloseIcon2, {})
4178
4918
  }
4179
4919
  )
4180
4920
  ] }),
@@ -4190,11 +4930,39 @@ function Section3({ label, children }) {
4190
4930
  ] });
4191
4931
  }
4192
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" }) });
4193
- function LeftPanel3({ handle, onResetView, onClose, isDark }) {
4194
- const [tool, setTool] = useState("move");
4195
- const [showAxes, setShowAxes] = useState(true);
4196
- const [showMesh, setShowMesh] = useState(false);
4197
- 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() {
4198
4966
  const [hover, setHover] = useState(null);
4199
4967
  const [portalReady, setPortalReady] = useState(false);
4200
4968
  const hoverTimerRef = useRef(null);
@@ -4204,17 +4972,6 @@ function LeftPanel3({ handle, onResetView, onClose, isDark }) {
4204
4972
  if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
4205
4973
  };
4206
4974
  }, []);
4207
- useEffect(() => {
4208
- if (!handle) return;
4209
- const sync = () => {
4210
- setTool(handle.getTool());
4211
- setShowAxes(handle.getShowAxes());
4212
- setShowMesh(handle.getShowMesh());
4213
- setCanUndo(handle.canUndo());
4214
- };
4215
- sync();
4216
- return handle.subscribe(sync);
4217
- }, [handle]);
4218
4975
  const showHover = useCallback((el, t) => {
4219
4976
  if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
4220
4977
  hoverTimerRef.current = setTimeout(() => {
@@ -4229,14 +4986,45 @@ function LeftPanel3({ handle, onResetView, onClose, isDark }) {
4229
4986
  }
4230
4987
  setHover(null);
4231
4988
  }, []);
4232
- const grouped = TOOLS_3D.reduce(
4233
- (acc, t) => {
4234
- var _a;
4235
- (acc[_a = t.group] ?? (acc[_a] = [])).push(t);
4236
- return acc;
4237
- },
4238
- {}
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]
4239
5026
  );
5027
+ const activeGroupTools = chordGroup ? grouped[chordGroup] ?? null : null;
4240
5028
  return /* @__PURE__ */ jsxs(Fragment, { children: [
4241
5029
  /* @__PURE__ */ jsxs(Shell3, { title: "H\xECnh h\u1ECDc 3D", icon: Geom3DIconHeader, onClose, isDark, children: [
4242
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: [
@@ -4272,10 +5060,7 @@ function LeftPanel3({ handle, onResetView, onClose, isDark }) {
4272
5060
  title: "Reset g\xF3c nh\xECn",
4273
5061
  "aria-label": "Reset view",
4274
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",
4275
- children: /* @__PURE__ */ jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
4276
- /* @__PURE__ */ jsx("path", { d: "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" }),
4277
- /* @__PURE__ */ jsx("path", { d: "M3 3v5h5" })
4278
- ] })
5063
+ children: /* @__PURE__ */ jsx(ResetViewIcon, {})
4279
5064
  }
4280
5065
  ),
4281
5066
  /* @__PURE__ */ jsx(
@@ -4287,34 +5072,95 @@ function LeftPanel3({ handle, onResetView, onClose, isDark }) {
4287
5072
  title: "Ho\xE0n t\xE1c (Ctrl/Cmd+Z)",
4288
5073
  "aria-label": "Ho\xE0n t\xE1c",
4289
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",
4290
- children: /* @__PURE__ */ jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
4291
- /* @__PURE__ */ jsx("polyline", { points: "3 7 3 13 9 13" }),
4292
- /* @__PURE__ */ jsx("path", { d: "M3.51 13a9 9 0 1 0 2.13-9.36L3 7" })
4293
- ] })
5075
+ children: /* @__PURE__ */ jsx(UndoIcon2, {})
4294
5076
  }
4295
5077
  )
4296
5078
  ] }) }),
4297
- 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(
4298
- 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",
4299
5150
  {
4300
- toolKey: t.key,
4301
- label: t.label,
4302
- hint: t.hint,
4303
- active: tool === t.key,
4304
- onClick: () => handle?.setTool(t.key),
4305
- icon: /* @__PURE__ */ jsx(
4306
- "span",
4307
- {
4308
- onMouseEnter: (e) => showHover(e.currentTarget.closest("button"), t),
4309
- onMouseLeave: hideHover,
4310
- onFocus: (e) => showHover(e.currentTarget.closest("button"), t),
4311
- onBlur: hideHover,
4312
- children: ICONS_3D[t.key]
4313
- }
4314
- )
4315
- },
4316
- t.key
4317
- )) }) }, 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
+ )
4318
5164
  ] }),
4319
5165
  portalReady && hover && typeof document !== "undefined" ? createPortal(
4320
5166
  /* @__PURE__ */ jsxs(
@@ -4338,107 +5184,71 @@ function LeftPanel3({ handle, onResetView, onClose, isDark }) {
4338
5184
  ) : null
4339
5185
  ] });
4340
5186
  }
4341
- var EditorPanel = forwardRef(function EditorPanel2({ isDark, initial, onInsert, onClose }, ref) {
4342
- const boardRef = useRef(null);
4343
- const [boardHandle, setBoardHandle] = useState(null);
4344
- const setBoard = useCallback((h) => {
4345
- boardRef.current = h;
4346
- setBoardHandle((prev) => prev === h ? prev : h);
4347
- }, []);
4348
- useImperativeHandle(
4349
- ref,
4350
- () => ({
4351
- tryInsert: () => {
4352
- const board = boardRef.current;
4353
- if (!board) return false;
4354
- const log = board.getCreationLog();
4355
- if (log.length === 0) return false;
4356
- const view = board.getViewState();
4357
- const state = {
4358
- version: 1,
4359
- bbox: board.getBbox(),
4360
- view,
4361
- showAxes: board.getShowAxes(),
4362
- showMesh: board.getShowMesh(),
4363
- elements: log
4364
- };
4365
- const snap = board.snapshotSVG();
4366
- onInsert(JSON.stringify(state), snap.svgString, snap.width, snap.height);
4367
- return true;
4368
- },
4369
- hasContent: () => (boardRef.current?.getCreationLog().length ?? 0) > 0
4370
- }),
4371
- [onInsert]
4372
- );
4373
- const handleResetView = useCallback(() => {
4374
- 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
+ }));
4375
5201
  }, []);
4376
- return /* @__PURE__ */ jsxs(
4377
- "div",
5202
+ return /* @__PURE__ */ jsx(
5203
+ MobileToolDrawer,
4378
5204
  {
4379
- "data-testid": "geom3d-editor-panel",
4380
- "data-stamp-area": "true",
4381
- style: {
4382
- position: "absolute",
4383
- left: "50%",
4384
- top: "50%",
4385
- transform: "translate(-50%, -50%)",
4386
- width: 900,
4387
- height: 700,
4388
- background: "#fff",
4389
- boxShadow: "0 6px 32px rgba(0,0,0,0.2)",
4390
- borderRadius: 8,
4391
- zIndex: 10,
4392
- display: "flex",
4393
- flexDirection: "column",
4394
- overflow: "hidden"
4395
- },
4396
- children: [
4397
- /* @__PURE__ */ jsxs(
4398
- "div",
4399
- {
4400
- style: {
4401
- display: "flex",
4402
- padding: "8px 12px",
4403
- borderBottom: "1px solid #eee",
4404
- alignItems: "center"
4405
- },
4406
- children: [
4407
- /* @__PURE__ */ jsx("span", { style: { fontWeight: 600 }, children: "H\xECnh h\u1ECDc kh\xF4ng gian (3D)" }),
4408
- /* @__PURE__ */ jsx("span", { style: { flex: 1 } }),
4409
- /* @__PURE__ */ jsx("button", { type: "button", onClick: onClose, children: "\u0110\xF3ng" })
4410
- ]
4411
- }
4412
- ),
4413
- /* @__PURE__ */ jsxs("div", { style: { position: "relative", flex: 1, minHeight: 0 }, children: [
4414
- /* @__PURE__ */ jsx(
4415
- LeftPanel3,
4416
- {
4417
- handle: boardHandle,
4418
- onResetView: handleResetView,
4419
- onClose,
4420
- isDark
4421
- }
4422
- ),
4423
- /* @__PURE__ */ jsx(
4424
- "div",
4425
- {
4426
- style: {
4427
- position: "absolute",
4428
- left: 120,
4429
- top: 0,
4430
- right: 0,
4431
- bottom: 0,
4432
- overflow: "hidden"
4433
- },
4434
- children: /* @__PURE__ */ jsx(MiniBoard3D, { ref: setBoard, isDark, initialState: initial })
4435
- }
4436
- )
4437
- ] })
4438
- ]
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)
4439
5245
  }
4440
5246
  );
4441
- });
5247
+ }
5248
+ function LeftPanel3(props) {
5249
+ if (props.isMobile) return /* @__PURE__ */ jsx(MobilePanel, { ...props });
5250
+ return /* @__PURE__ */ jsx(DesktopPanel, { ...props });
5251
+ }
4442
5252
 
4443
5253
  // src/stamps/geometry-3d/serialize.ts
4444
5254
  function isGeometry3DCustomData(data) {
@@ -4460,10 +5270,13 @@ function parseSerializedBoard3D(json) {
4460
5270
  }
4461
5271
  return parsed;
4462
5272
  }
5273
+
5274
+ // src/stamps/geometry-3d/render.ts
4463
5275
  var OUTPUT_WIDTH = 1024;
4464
5276
  var OUTPUT_HEIGHT = 768;
4465
5277
  async function renderGeometry3DSvgFromState(jsonState) {
4466
5278
  const state = parseSerializedBoard3D(jsonState);
5279
+ const JXG = (await import('jsxgraph')).default;
4467
5280
  const div = document.createElement("div");
4468
5281
  div.style.cssText = `position:absolute;left:-9999px;top:-9999px;width:${OUTPUT_WIDTH}px;height:${OUTPUT_HEIGHT}px;`;
4469
5282
  document.body.appendChild(div);
@@ -4520,66 +5333,1528 @@ async function renderGeometry3DSvgFromState(jsonState) {
4520
5333
  JXG.JSXGraph.freeBoard(board);
4521
5334
  } catch {
4522
5335
  }
4523
- return { svgString, width: OUTPUT_WIDTH, height: OUTPUT_HEIGHT };
4524
- } finally {
4525
- document.body.removeChild(div);
4526
- }
4527
- }
4528
- function parseInitial(editingElement) {
4529
- if (!editingElement) return null;
4530
- if (!isGeometry3DCustomData(editingElement.customData)) return null;
4531
- try {
4532
- return parseSerializedBoard3D(editingElement.customData.jsonState);
4533
- } catch {
4534
- return null;
4535
- }
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";
4536
6754
  }
4537
- var Geometry3DStampHost = forwardRef(
4538
- function Geometry3DStampHost2({ api, editingElement, onClose, isDark }, ref) {
4539
- const editorRef = useRef(null);
4540
- const initial = useMemo(
4541
- () => parseInitial(editingElement),
4542
- [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
4543
6774
  );
6775
+ const [errorsSnapshot, setErrorsSnapshot] = useState({});
4544
6776
  const handleInsert = useCallback(
4545
- async (jsonState, svgString, width, height) => {
6777
+ async (jsonState, svgString) => {
4546
6778
  if (!api) return;
4547
- await insertStampImage(api, {
4548
- svgString,
4549
- makeCustomData: () => ({
4550
- kind: "geometry3d",
4551
- version: 1,
4552
- jsonState,
4553
- svgWidth: width,
4554
- svgHeight: height
4555
- }),
4556
- editingElementId: editingElement?.id ?? null
4557
- });
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
+ }
4558
6794
  onClose();
4559
6795
  },
4560
- [api, editingElement, onClose]
6796
+ [api, editingElement?.id, onClose]
4561
6797
  );
4562
6798
  useImperativeHandle(
4563
6799
  ref,
4564
6800
  () => ({
4565
- tryInsert: () => editorRef.current?.tryInsert() ?? false,
4566
- hasContent: () => editorRef.current?.hasContent() ?? false
6801
+ tryInsert: () => panelRef.current?.insert() ?? false,
6802
+ hasContent: () => panelRef.current?.hasContent() ?? false
4567
6803
  }),
4568
6804
  []
4569
6805
  );
4570
- return /* @__PURE__ */ jsx(
4571
- EditorPanel,
4572
- {
4573
- ref: editorRef,
4574
- isDark,
4575
- initial,
4576
- onInsert: handleInsert,
4577
- onClose
4578
- }
4579
- );
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
+ ] });
4580
6855
  }
4581
6856
  );
4582
- var Geometry3DIcon = /* @__PURE__ */ jsxs(
6857
+ var Graph2DIcon = /* @__PURE__ */ jsxs(
4583
6858
  "svg",
4584
6859
  {
4585
6860
  width: "20",
@@ -4592,47 +6867,45 @@ var Geometry3DIcon = /* @__PURE__ */ jsxs(
4592
6867
  strokeLinejoin: "round",
4593
6868
  "aria-hidden": "true",
4594
6869
  children: [
4595
- /* @__PURE__ */ jsx("path", { d: "M12 3 L20 8 L20 16 L12 21 L4 16 L4 8 Z" }),
4596
- /* @__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" })
4597
6873
  ]
4598
6874
  }
4599
6875
  );
4600
- var geometry3dStamp = {
4601
- kind: "geometry3d",
4602
- shortcutKey: "d",
4603
- toolbarLabel: "D",
4604
- toolbarTitle: "H\xECnh 3D (D)",
4605
- toolbarIcon: Geometry3DIcon,
4606
- toolbarTestId: "stamp-toolbar-geometry3d",
4607
- 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,
4608
6884
  async renderSvgFromCustomData(data) {
4609
- if (!isGeometry3DCustomData(data)) {
4610
- 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");
4611
6887
  }
4612
- const { svgString } = await renderGeometry3DSvgFromState(data.jsonState);
4613
- return svgString;
6888
+ return renderGraph2dSvgFromState(data.jsonState);
4614
6889
  },
4615
- restoreFileFromCustomData: async (element) => {
6890
+ async restoreFileFromCustomData(element) {
4616
6891
  const data = element.customData;
4617
6892
  const fileId = element.fileId;
4618
6893
  if (!data || !fileId) return null;
4619
- if (!isGeometry3DCustomData(data)) return null;
4620
- try {
4621
- const { svgString } = await renderGeometry3DSvgFromState(data.jsonState);
4622
- const dataURL = `data:image/svg+xml;base64,${typeof btoa !== "undefined" ? btoa(unescape(encodeURIComponent(svgString))) : Buffer.from(svgString).toString("base64")}`;
4623
- return { fileId, dataURL, mimeType: "image/svg+xml" };
4624
- } catch {
4625
- return null;
4626
- }
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" };
4627
6899
  },
4628
- Host: Geometry3DStampHost
6900
+ Host: Graph2DStampHost
4629
6901
  };
4630
6902
 
4631
6903
  // src/stamps/shared/registry.ts
4632
6904
  var DEFAULT_STAMPS = Object.freeze([
4633
6905
  geometryStamp,
4634
6906
  latexStamp,
4635
- geometry3dStamp
6907
+ geometry3dStamp,
6908
+ graph2dStamp
4636
6909
  ]);
4637
6910
  function findStampForCustomData(data, stamps = DEFAULT_STAMPS) {
4638
6911
  for (const s of stamps) {
@@ -4643,36 +6916,90 @@ function findStampForCustomData(data, stamps = DEFAULT_STAMPS) {
4643
6916
  function isStampElement(element, stamps = DEFAULT_STAMPS) {
4644
6917
  return findStampForCustomData(element.customData, stamps) !== null;
4645
6918
  }
4646
- 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";
4647
6921
  function ToolbarInjector({
4648
6922
  enabled,
4649
6923
  activeStampKind,
4650
6924
  onToggle,
4651
6925
  stamps = DEFAULT_STAMPS
4652
6926
  }) {
4653
- 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);
4654
6933
  useEffect(() => {
4655
6934
  if (!enabled) {
4656
- 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();
4657
6979
  return;
4658
6980
  }
4659
6981
  let cancelled = false;
4660
6982
  let attempts = 0;
4661
6983
  let observer = null;
4662
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
+ };
4663
6992
  const tryMount = () => {
4664
6993
  if (cancelled) return;
4665
6994
  const container = document.querySelector(".excalidraw .App-toolbar .Stack_horizontal") ?? document.querySelector(".App-toolbar .Stack_horizontal");
4666
6995
  if (!container) {
4667
- if (attempts++ < 20) {
4668
- timer = setTimeout(tryMount, 100);
4669
- }
6996
+ if (attempts++ < 20) timer = setTimeout(tryMount, 100);
4670
6997
  return;
4671
6998
  }
4672
- let wrapper = container.querySelector("#" + WRAPPER_ID);
6999
+ let wrapper = container.querySelector("#" + TOOLBAR_WRAPPER_ID);
4673
7000
  if (!wrapper) {
4674
7001
  wrapper = document.createElement("div");
4675
- wrapper.id = WRAPPER_ID;
7002
+ wrapper.id = TOOLBAR_WRAPPER_ID;
4676
7003
  wrapper.className = "Stamp-toolbar-injector";
4677
7004
  wrapper.setAttribute("data-stamp-area", "true");
4678
7005
  wrapper.style.display = "inline-flex";
@@ -4688,13 +7015,13 @@ function ToolbarInjector({
4688
7015
  container.appendChild(wrapper);
4689
7016
  }
4690
7017
  }
4691
- setMountNode(wrapper);
7018
+ apply(wrapper);
4692
7019
  };
4693
7020
  tryMount();
4694
7021
  const root = document.querySelector(".excalidraw") ?? document.body;
4695
7022
  observer = new MutationObserver(() => {
4696
7023
  if (cancelled) return;
4697
- const stillThere = document.getElementById(WRAPPER_ID);
7024
+ const stillThere = document.getElementById(TOOLBAR_WRAPPER_ID);
4698
7025
  if (!stillThere) {
4699
7026
  attempts = 0;
4700
7027
  tryMount();
@@ -4705,11 +7032,73 @@ function ToolbarInjector({
4705
7032
  cancelled = true;
4706
7033
  if (timer) clearTimeout(timer);
4707
7034
  observer?.disconnect();
4708
- document.getElementById(WRAPPER_ID)?.remove();
7035
+ document.getElementById(TOOLBAR_WRAPPER_ID)?.remove();
4709
7036
  };
4710
- }, [enabled]);
4711
- if (!enabled || !mountNode) return null;
4712
- 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(
4713
7102
  /* @__PURE__ */ jsx(Fragment, { children: stamps.map((stamp) => /* @__PURE__ */ jsx(
4714
7103
  StampToolButton,
4715
7104
  {
@@ -4722,8 +7111,42 @@ function ToolbarInjector({
4722
7111
  },
4723
7112
  stamp.kind
4724
7113
  )) }),
4725
- mountNode
4726
- );
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
+ ] });
4727
7150
  }
4728
7151
  function StampToolButton({ icon, keybind, label, active, onClick, dataTestId }) {
4729
7152
  return /* @__PURE__ */ jsxs(
@@ -4788,6 +7211,54 @@ function StampToolButton({ icon, keybind, label, active, onClick, dataTestId })
4788
7211
  }
4789
7212
  );
4790
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
+ }
4791
7262
  function isEditableTarget(t) {
4792
7263
  if (!t || !(t instanceof HTMLElement)) return false;
4793
7264
  if (t.isContentEditable) return true;
@@ -5087,7 +7558,7 @@ async function pruneFiles(storageKey, keepIds) {
5087
7558
  }
5088
7559
  }
5089
7560
  var Excalidraw = dynamic(
5090
- async () => (await import('./ExcalidrawWithMenus-KBLDWPM2.mjs')).ExcalidrawWithMenus,
7561
+ async () => (await import('./ExcalidrawWithMenus-EAVPOPJZ.mjs')).ExcalidrawWithMenus,
5091
7562
  {
5092
7563
  ssr: false,
5093
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" })
@@ -5105,7 +7576,9 @@ function Whiteboard({
5105
7576
  stamps = DEFAULT_STAMPS
5106
7577
  }) {
5107
7578
  const [api, setApi] = useState(null);
7579
+ const apiRef = useRef(null);
5108
7580
  const [isDarkTheme, setIsDarkTheme] = useState(false);
7581
+ const isDarkThemeRef = useRef(false);
5109
7582
  const knownFileIdsRef = useRef(/* @__PURE__ */ new Set());
5110
7583
  const lastSceneHashRef = useRef("");
5111
7584
  const sceneThrottleRef = useRef(null);
@@ -5166,7 +7639,10 @@ function Whiteboard({
5166
7639
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
5167
7640
  (elements, appState, files) => {
5168
7641
  const nextDark = appState?.theme === "dark";
5169
- setIsDarkTheme((prev) => prev === nextDark ? prev : nextDark);
7642
+ if (isDarkThemeRef.current !== nextDark) {
7643
+ isDarkThemeRef.current = nextDark;
7644
+ queueMicrotask(() => setIsDarkTheme(nextDark));
7645
+ }
5170
7646
  if (readOnly) return;
5171
7647
  latestSceneRef.current = { elements, appState };
5172
7648
  const cropId = appState?.croppingElementId;
@@ -5176,12 +7652,17 @@ function Whiteboard({
5176
7652
  const stamp = findStampForCustomData(el.customData, stamps);
5177
7653
  if (stamp) {
5178
7654
  handledCropIdRef.current = cropId;
5179
- api.updateScene({
5180
- appState: { ...appState, croppingElementId: null, selectedElementIds: {} }
5181
- });
5182
- openStamp(stamp.kind, {
5183
- id: el.id,
5184
- 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 });
5185
7666
  });
5186
7667
  return;
5187
7668
  }
@@ -5432,8 +7913,12 @@ function Whiteboard({
5432
7913
  Excalidraw,
5433
7914
  {
5434
7915
  excalidrawAPI: (a) => {
5435
- setApi(a);
5436
- onApi?.(a);
7916
+ if (apiRef.current === a) return;
7917
+ apiRef.current = a;
7918
+ queueMicrotask(() => {
7919
+ setApi(a);
7920
+ onApi?.(a);
7921
+ });
5437
7922
  },
5438
7923
  langCode,
5439
7924
  viewModeEnabled: readOnly,
@@ -5470,6 +7955,6 @@ function Whiteboard({
5470
7955
  ] });
5471
7956
  }
5472
7957
 
5473
- 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 };
5474
7959
  //# sourceMappingURL=index.mjs.map
5475
7960
  //# sourceMappingURL=index.mjs.map