@open-slide/core 1.0.5 → 1.1.0

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.
Files changed (40) hide show
  1. package/dist/{build-CoON6kTb.js → build-DSqSio-T.js} +1 -1
  2. package/dist/cli/bin.js +3 -3
  3. package/dist/{config-D2y1AXaN.d.ts → config-C7vMYzFD.d.ts} +1 -1
  4. package/dist/{config-Bxtztw-H.js → config-KdiYeWtK.js} +114 -1
  5. package/dist/{dev-IezNC17X.js → dev-B_GVbr11.js} +1 -1
  6. package/dist/index.d.ts +2 -2
  7. package/dist/locale/index.d.ts +1 -1
  8. package/dist/locale/index.js +40 -4
  9. package/dist/{preview-BwYjtENY.js → preview-D_mxhj7w.js} +1 -1
  10. package/dist/{types-BVvl_xup.d.ts → types-DYgVpIGo.d.ts} +9 -0
  11. package/dist/vite/index.d.ts +2 -2
  12. package/dist/vite/index.js +1 -1
  13. package/package.json +5 -1
  14. package/src/app/components/inspector/image-crop-dialog.tsx +168 -0
  15. package/src/app/components/inspector/inspect-overlay.tsx +96 -19
  16. package/src/app/components/inspector/inspector-panel.tsx +46 -13
  17. package/src/app/components/inspector/inspector-provider.tsx +83 -1
  18. package/src/app/components/inspector/save-bar.tsx +0 -3
  19. package/src/app/components/player.tsx +22 -26
  20. package/src/app/components/present/overview-grid.tsx +0 -5
  21. package/src/app/components/present/use-idle.ts +6 -4
  22. package/src/app/components/present/use-presenter-channel.ts +3 -10
  23. package/src/app/components/sidebar/folder-item.tsx +0 -2
  24. package/src/app/components/sidebar/icon-picker.tsx +0 -3
  25. package/src/app/components/slide-canvas.tsx +1 -10
  26. package/src/app/components/style-panel/design-provider.tsx +15 -6
  27. package/src/app/components/style-panel/style-panel.tsx +23 -11
  28. package/src/app/components/thumbnail-rail.tsx +220 -53
  29. package/src/app/lib/design-presets.ts +94 -0
  30. package/src/app/lib/export-html.ts +1 -9
  31. package/src/app/lib/export-pdf.ts +0 -5
  32. package/src/app/lib/print-ready.ts +0 -4
  33. package/src/app/lib/sdk.ts +1 -2
  34. package/src/app/routes/presenter.tsx +27 -24
  35. package/src/app/routes/slide.tsx +53 -1
  36. package/src/locale/en.ts +9 -0
  37. package/src/locale/ja.ts +9 -0
  38. package/src/locale/types.ts +9 -0
  39. package/src/locale/zh-cn.ts +9 -0
  40. package/src/locale/zh-tw.ts +9 -0
@@ -1,5 +1,5 @@
1
1
  import "./design-C13iz9_4.js";
2
- import { createViteConfig } from "./config-Bxtztw-H.js";
2
+ import { createViteConfig } from "./config-KdiYeWtK.js";
3
3
  import path from "node:path";
4
4
  import { build as build$1, mergeConfig } from "vite";
5
5
 
package/dist/cli/bin.js CHANGED
@@ -57,15 +57,15 @@ async function run(argv) {
57
57
  program.name("open-slide").description("Author slides — we handle the Vite/React stack.").version(version, "-v, --version", "print version").helpOption("-h, --help", "show help").showHelpAfterError(chalk.dim("(run `open-slide --help` for usage)"));
58
58
  program.command("dev").description("Start the dev server").addOption(new Option("-p, --port <port>", "port to listen on").argParser(parsePort)).addOption(new Option("--host [host]", "expose on the network (optional host)")).option("--open", "open the browser on start").option("--no-skills-check", "skip the built-in skills drift check").action(async (flags) => {
59
59
  if (flags.skillsCheck !== false) await runSkillsDriftCheck(resolveBuiltinSkillsDir());
60
- const { dev } = await import("../dev-IezNC17X.js");
60
+ const { dev } = await import("../dev-B_GVbr11.js");
61
61
  await dev(flags);
62
62
  });
63
63
  program.command("build").description("Build a static site").option("--out-dir <dir>", "output directory (defaults to `dist`)").action(async (flags) => {
64
- const { build } = await import("../build-CoON6kTb.js");
64
+ const { build } = await import("../build-DSqSio-T.js");
65
65
  await build(flags);
66
66
  });
67
67
  program.command("preview").description("Preview the production build").addOption(new Option("-p, --port <port>", "port to listen on").argParser(parsePort)).addOption(new Option("--host [host]", "expose on the network (optional host)")).option("--open", "open the browser on start").action(async (flags) => {
68
- const { preview } = await import("../preview-BwYjtENY.js");
68
+ const { preview } = await import("../preview-D_mxhj7w.js");
69
69
  await preview(flags);
70
70
  });
71
71
  program.command("sync:skills").description("Sync built-in skills from @open-slide/core into this workspace").option("--dry-run", "show what would change without writing").action(async (flags) => {
@@ -1,4 +1,4 @@
1
- import { Locale } from "./types-BVvl_xup.js";
1
+ import { Locale } from "./types-DYgVpIGo.js";
2
2
 
3
3
  //#region src/config.d.ts
4
4
  type OpenSlideBuildConfig = {
@@ -647,8 +647,11 @@ function planReplacePlaceholder(ast, element, assetPath) {
647
647
  const { identifier, importSplice } = planAssetImport(ast, assetPath);
648
648
  const styleParts = [];
649
649
  if (width != null) styleParts.push(`width: ${width}`);
650
+ else if (height != null) styleParts.push(`width: '100%'`);
650
651
  if (height != null) styleParts.push(`height: ${height}`);
652
+ else if (width != null) styleParts.push(`height: '100%'`);
651
653
  styleParts.push(`objectFit: 'cover'`);
654
+ styleParts.push(`objectPosition: '50% 50%'`);
652
655
  const replacement = `<img src={${identifier}} alt=${jsString$1(hint)} style={{ ${styleParts.join(", ")} }} />`;
653
656
  return {
654
657
  importSplice,
@@ -1521,6 +1524,84 @@ function updateMetaTitleInSource(source, title) {
1521
1524
  const insertion = `export const meta: SlideMeta = { title: ${newLiteral} };\n\n`;
1522
1525
  return source.slice(0, exportDefaultIdx) + insertion + source.slice(exportDefaultIdx);
1523
1526
  }
1527
+ function findDefaultExportArray(source) {
1528
+ let ast;
1529
+ try {
1530
+ ast = parse(source, {
1531
+ sourceType: "module",
1532
+ plugins: ["typescript", "jsx"],
1533
+ errorRecovery: true
1534
+ });
1535
+ } catch {
1536
+ return null;
1537
+ }
1538
+ const body = ast.program?.body ?? [];
1539
+ for (const node of body) {
1540
+ if (node.type !== "ExportDefaultDeclaration") continue;
1541
+ let inner = node.declaration;
1542
+ while (inner && (inner.type === "TSAsExpression" || inner.type === "TSSatisfiesExpression")) inner = inner.expression;
1543
+ if (!inner || inner.type !== "ArrayExpression") return null;
1544
+ const arrayStart = inner.start;
1545
+ const arrayEnd = inner.end;
1546
+ const rawElements = inner.elements ?? [];
1547
+ const elements = [];
1548
+ for (const el of rawElements) {
1549
+ if (!el || typeof el.start !== "number" || typeof el.end !== "number") return null;
1550
+ elements.push({
1551
+ start: el.start,
1552
+ end: el.end
1553
+ });
1554
+ }
1555
+ return {
1556
+ elements,
1557
+ arrayStart,
1558
+ arrayEnd
1559
+ };
1560
+ }
1561
+ return null;
1562
+ }
1563
+ /**
1564
+ * Rewrite `export default [...]` so its elements appear in the requested order.
1565
+ *
1566
+ * `order[i]` is the original index that should land at new position `i`. The
1567
+ * function preserves each element's exact source slice (including any inline
1568
+ * comments that hug an identifier) and keeps the inter-element separator slots
1569
+ * in their original positions, so a 3-page array `[A, B, C]` reordered to
1570
+ * `[2, 0, 1]` becomes `[C, A, B]` with the same indentation and trailing
1571
+ * commas the author wrote.
1572
+ *
1573
+ * Returns `null` when the file's default export isn't an array literal, or the
1574
+ * order is not a valid permutation of `[0, n-1]`.
1575
+ */
1576
+ function reorderDefaultExportPagesInSource(source, order) {
1577
+ const found = findDefaultExportArray(source);
1578
+ if (!found) return null;
1579
+ const { elements, arrayStart, arrayEnd } = found;
1580
+ const n = elements.length;
1581
+ if (order.length !== n) return null;
1582
+ const seen = new Set();
1583
+ for (const idx of order) {
1584
+ if (!Number.isInteger(idx) || idx < 0 || idx >= n) return null;
1585
+ if (seen.has(idx)) return null;
1586
+ seen.add(idx);
1587
+ }
1588
+ if (n === 0) return source;
1589
+ let identity = true;
1590
+ for (let i = 0; i < n; i++) if (order[i] !== i) {
1591
+ identity = false;
1592
+ break;
1593
+ }
1594
+ if (identity) return source;
1595
+ const prefix = source.slice(arrayStart, elements[0].start);
1596
+ const suffix = source.slice(elements[n - 1].end, arrayEnd);
1597
+ const separators = [];
1598
+ for (let i = 0; i < n - 1; i++) separators.push(source.slice(elements[i].end, elements[i + 1].start));
1599
+ const elementText = elements.map((el) => source.slice(el.start, el.end));
1600
+ let rebuilt = prefix + elementText[order[0]];
1601
+ for (let i = 1; i < n; i++) rebuilt += separators[i - 1] + elementText[order[i]];
1602
+ rebuilt += suffix;
1603
+ return source.slice(0, arrayStart) + rebuilt + source.slice(arrayEnd);
1604
+ }
1524
1605
  function validateIcon(v) {
1525
1606
  if (!v || typeof v !== "object") return null;
1526
1607
  const icon = v;
@@ -1577,6 +1658,34 @@ function filesPlugin(opts) {
1577
1658
  const url = new URL(req.url ?? "/", "http://local");
1578
1659
  const method = req.method ?? "GET";
1579
1660
  try {
1661
+ const reorderMatch = url.pathname.match(/^\/([^/]+)\/reorder$/);
1662
+ if (reorderMatch && method === "PUT") {
1663
+ const slideId$1 = reorderMatch[1];
1664
+ if (!SLIDE_ID_RE.test(slideId$1)) return json(res, 400, { error: "invalid slideId" });
1665
+ const body = await readBody(req);
1666
+ if (!Array.isArray(body.order)) return json(res, 400, { error: "invalid order" });
1667
+ const order = [];
1668
+ for (const v of body.order) {
1669
+ if (!Number.isInteger(v)) return json(res, 400, { error: "invalid order" });
1670
+ order.push(v);
1671
+ }
1672
+ const entry = resolveSlideEntry(slidesRoot, slideId$1);
1673
+ if (!entry) return json(res, 400, { error: "invalid slideId" });
1674
+ let source;
1675
+ try {
1676
+ source = await fs.readFile(entry, "utf8");
1677
+ } catch {
1678
+ return json(res, 404, { error: "slide not found" });
1679
+ }
1680
+ const updated = reorderDefaultExportPagesInSource(source, order);
1681
+ if (updated === null) return json(res, 422, { error: "could not reorder pages — order must be a permutation of the existing array" });
1682
+ if (updated !== source) await fs.writeFile(entry, updated, "utf8");
1683
+ return json(res, 200, {
1684
+ ok: true,
1685
+ slideId: slideId$1,
1686
+ order
1687
+ });
1688
+ }
1580
1689
  const idMatch = url.pathname.match(/^\/([^/]+)$/);
1581
1690
  if (!idMatch) return next();
1582
1691
  const slideId = idMatch[1];
@@ -1962,7 +2071,7 @@ function toId(absFile, slidesRoot) {
1962
2071
  function generateSlidesModule(files, slidesRoot, isDev) {
1963
2072
  const entries = files.map((abs) => {
1964
2073
  const id = toId(abs, slidesRoot);
1965
- const importPath = isDev ? `/@fs${abs}` : abs;
2074
+ const importPath = isDev ? `/@fs/${abs.replace(/^\/+/, "")}` : abs;
1966
2075
  return {
1967
2076
  id,
1968
2077
  importPath
@@ -2130,6 +2239,10 @@ async function createViteConfig(opts) {
2130
2239
  optimizeDeps: {
2131
2240
  entries: [path.join(APP_ROOT, "main.tsx")],
2132
2241
  include: [
2242
+ "react",
2243
+ "react-dom",
2244
+ "react-dom/client",
2245
+ "next-themes",
2133
2246
  "react-router-dom",
2134
2247
  "radix-ui",
2135
2248
  "lucide-react",
@@ -1,5 +1,5 @@
1
1
  import "./design-C13iz9_4.js";
2
- import { createViteConfig } from "./config-Bxtztw-H.js";
2
+ import { createViteConfig } from "./config-KdiYeWtK.js";
3
3
  import { createServer, mergeConfig } from "vite";
4
4
 
5
5
  //#region src/cli/dev.ts
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { Locale, Plural } from "./types-BVvl_xup.js";
2
- import { OpenSlideConfig } from "./config-D2y1AXaN.js";
1
+ import { Locale, Plural } from "./types-DYgVpIGo.js";
2
+ import { OpenSlideConfig } from "./config-C7vMYzFD.js";
3
3
  import * as react_jsx_runtime0 from "react/jsx-runtime";
4
4
  import { CSSProperties, ComponentType, HTMLAttributes } from "react";
5
5
 
@@ -1,4 +1,4 @@
1
- import { Locale, Plural } from "../types-BVvl_xup.js";
1
+ import { Locale, Plural } from "../types-DYgVpIGo.js";
2
2
 
3
3
  //#region src/locale/en.d.ts
4
4
  declare const en: Locale;
@@ -177,6 +177,13 @@ const en = {
177
177
  pickerLoading: "Loading…",
178
178
  pickerEmpty: "No images in this slide's assets folder yet. Add some from the Assets tab.",
179
179
  placeholderHintLabel: "Hint:",
180
+ crop: "Crop…",
181
+ cropDialogTitle: "Crop image",
182
+ cropDialogDescription: "Drag the frame to choose what stays visible.",
183
+ cropFitCover: "Fill",
184
+ cropFitContain: "Fit",
185
+ cropApply: "Apply",
186
+ cropResetAria: "Reset crop",
180
187
  noteForAgent: "Note for the agent",
181
188
  noteAgentPlaceholder: "Describe a change for the agent…",
182
189
  noteShortcutHint: "⌘↵ to send",
@@ -214,7 +221,9 @@ const en = {
214
221
  radiusLabel: "Radius",
215
222
  designToggle: "Design",
216
223
  designToggleTitle: "Design tokens",
217
- fontPresetCustom: "Custom…"
224
+ fontPresetCustom: "Custom…",
225
+ shuffleAria: "Shuffle design",
226
+ shuffleTitle: "Shuffle for inspiration"
218
227
  },
219
228
  asset: {
220
229
  devOnlyMessage: "Asset management is only available in dev mode.",
@@ -483,6 +492,13 @@ const ja = {
483
492
  pickerLoading: "読み込み中…",
484
493
  pickerEmpty: "このスライドのアセットフォルダにまだ画像がありません。「アセット」タブから追加してください。",
485
494
  placeholderHintLabel: "ヒント:",
495
+ crop: "トリミング…",
496
+ cropDialogTitle: "画像をトリミング",
497
+ cropDialogDescription: "枠をドラッグして表示する範囲を選択します。",
498
+ cropFitCover: "塗りつぶす",
499
+ cropFitContain: "全体表示",
500
+ cropApply: "適用",
501
+ cropResetAria: "トリミングをリセット",
486
502
  noteForAgent: "エージェントへのメモ",
487
503
  noteAgentPlaceholder: "エージェントに依頼する変更を記述…",
488
504
  noteShortcutHint: "⌘↵ で送信",
@@ -520,7 +536,9 @@ const ja = {
520
536
  radiusLabel: "角丸",
521
537
  designToggle: "デザイン",
522
538
  designToggleTitle: "デザイントークン",
523
- fontPresetCustom: "カスタム…"
539
+ fontPresetCustom: "カスタム…",
540
+ shuffleAria: "デザインをシャッフル",
541
+ shuffleTitle: "シャッフルしてインスピレーションを得る"
524
542
  },
525
543
  asset: {
526
544
  devOnlyMessage: "アセット管理は開発モードでのみ利用できます。",
@@ -777,6 +795,13 @@ const zhCN = {
777
795
  pickerLoading: "加载中…",
778
796
  pickerEmpty: "该幻灯片的素材文件夹中尚无图片。请从「素材」标签页添加。",
779
797
  placeholderHintLabel: "提示:",
798
+ crop: "裁剪…",
799
+ cropDialogTitle: "裁剪图片",
800
+ cropDialogDescription: "拖动框线决定要保留的可见区域。",
801
+ cropFitCover: "填满",
802
+ cropFitContain: "完整显示",
803
+ cropApply: "应用",
804
+ cropResetAria: "重置裁剪",
780
805
  noteForAgent: "给代理的备注",
781
806
  noteAgentPlaceholder: "描述你希望代理执行的更改…",
782
807
  noteShortcutHint: "⌘↵ 发送",
@@ -814,7 +839,9 @@ const zhCN = {
814
839
  radiusLabel: "圆角",
815
840
  designToggle: "设计",
816
841
  designToggleTitle: "设计样式",
817
- fontPresetCustom: "自定义…"
842
+ fontPresetCustom: "自定义…",
843
+ shuffleAria: "随机设计",
844
+ shuffleTitle: "随机配色获取灵感"
818
845
  },
819
846
  asset: {
820
847
  devOnlyMessage: "素材管理仅在开发模式下可用。",
@@ -1071,6 +1098,13 @@ const zhTW = {
1071
1098
  pickerLoading: "載入中…",
1072
1099
  pickerEmpty: "此投影片的素材資料夾尚未有圖片。請從「素材」分頁加入。",
1073
1100
  placeholderHintLabel: "提示:",
1101
+ crop: "裁切…",
1102
+ cropDialogTitle: "裁切圖片",
1103
+ cropDialogDescription: "拖曳框線決定要保留的可見範圍。",
1104
+ cropFitCover: "填滿",
1105
+ cropFitContain: "完整顯示",
1106
+ cropApply: "套用",
1107
+ cropResetAria: "重設裁切",
1074
1108
  noteForAgent: "給代理的備註",
1075
1109
  noteAgentPlaceholder: "描述你希望代理進行的修改…",
1076
1110
  noteShortcutHint: "⌘↵ 送出",
@@ -1108,7 +1142,9 @@ const zhTW = {
1108
1142
  radiusLabel: "圓角",
1109
1143
  designToggle: "設計",
1110
1144
  designToggleTitle: "設計樣式",
1111
- fontPresetCustom: "自訂…"
1145
+ fontPresetCustom: "自訂…",
1146
+ shuffleAria: "隨機設計",
1147
+ shuffleTitle: "隨機配色獲取靈感"
1112
1148
  },
1113
1149
  asset: {
1114
1150
  devOnlyMessage: "素材管理僅在開發模式下可用。",
@@ -1,5 +1,5 @@
1
1
  import "./design-C13iz9_4.js";
2
- import { createViteConfig } from "./config-Bxtztw-H.js";
2
+ import { createViteConfig } from "./config-KdiYeWtK.js";
3
3
  import { mergeConfig, preview as preview$1 } from "vite";
4
4
 
5
5
  //#region src/cli/preview.ts
@@ -187,6 +187,13 @@ type Locale = {
187
187
  pickerLoading: string;
188
188
  pickerEmpty: string;
189
189
  placeholderHintLabel: string;
190
+ crop: string;
191
+ cropDialogTitle: string;
192
+ cropDialogDescription: string;
193
+ cropFitCover: string;
194
+ cropFitContain: string;
195
+ cropApply: string;
196
+ cropResetAria: string;
190
197
  noteForAgent: string;
191
198
  noteAgentPlaceholder: string;
192
199
  noteShortcutHint: string;
@@ -223,6 +230,8 @@ type Locale = {
223
230
  designToggle: string;
224
231
  designToggleTitle: string;
225
232
  fontPresetCustom: string;
233
+ shuffleAria: string;
234
+ shuffleTitle: string;
226
235
  };
227
236
  asset: {
228
237
  devOnlyMessage: string;
@@ -1,5 +1,5 @@
1
- import "../types-BVvl_xup.js";
2
- import { OpenSlideConfig } from "../config-D2y1AXaN.js";
1
+ import "../types-DYgVpIGo.js";
2
+ import { OpenSlideConfig } from "../config-C7vMYzFD.js";
3
3
  import { InlineConfig } from "vite";
4
4
 
5
5
  //#region src/vite/config.d.ts
@@ -1,4 +1,4 @@
1
1
  import "../design-C13iz9_4.js";
2
- import { createViteConfig } from "../config-Bxtztw-H.js";
2
+ import { createViteConfig } from "../config-KdiYeWtK.js";
3
3
 
4
4
  export { createViteConfig };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-slide/core",
3
- "version": "1.0.5",
3
+ "version": "1.1.0",
4
4
  "description": "Runtime and CLI for open-slide — write slides in slides/, we handle the rest.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -53,6 +53,9 @@
53
53
  "dependencies": {
54
54
  "@babel/parser": "^7.29.2",
55
55
  "@babel/types": "^7.29.0",
56
+ "@dnd-kit/core": "^6.3.1",
57
+ "@dnd-kit/sortable": "^10.0.0",
58
+ "@dnd-kit/utilities": "^3.2.2",
56
59
  "@fontsource-variable/geist": "^5.2.8",
57
60
  "@tailwindcss/vite": "^4.2.2",
58
61
  "@vitejs/plugin-react": "^4.3.3",
@@ -68,6 +71,7 @@
68
71
  "radix-ui": "^1.4.3",
69
72
  "react": "^18.3.1",
70
73
  "react-dom": "^18.3.1",
74
+ "react-image-crop": "^11.0.10",
71
75
  "react-router-dom": "^6.26.2",
72
76
  "shadcn": "^4.3.0",
73
77
  "sonner": "^2.0.7",
@@ -0,0 +1,168 @@
1
+ import { type SyntheticEvent, useEffect, useRef, useState } from 'react';
2
+ import ReactCrop, { type Crop, type PercentCrop } from 'react-image-crop';
3
+ import 'react-image-crop/dist/ReactCrop.css';
4
+ import { Button } from '@/components/ui/button';
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogDescription,
9
+ DialogFooter,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ } from '@/components/ui/dialog';
13
+ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
14
+ import { useLocale } from '@/lib/use-locale';
15
+
16
+ export type ImageCropResult = {
17
+ fit: 'cover' | 'contain';
18
+ x: number;
19
+ y: number;
20
+ };
21
+
22
+ export function ImageCropDialog({
23
+ src,
24
+ targetWidth,
25
+ targetHeight,
26
+ initialFit,
27
+ initialPosition,
28
+ onClose,
29
+ onApply,
30
+ }: {
31
+ src: string;
32
+ targetWidth: number;
33
+ targetHeight: number;
34
+ initialFit: 'cover' | 'contain';
35
+ initialPosition: { x: number; y: number };
36
+ onClose: () => void;
37
+ onApply: (result: ImageCropResult) => void;
38
+ }) {
39
+ const t = useLocale();
40
+ const [fit, setFit] = useState<'cover' | 'contain'>(initialFit);
41
+ const aspect = targetWidth > 0 && targetHeight > 0 ? targetWidth / targetHeight : 1;
42
+ const [crop, setCrop] = useState<Crop | undefined>(undefined);
43
+ const imgRef = useRef<HTMLImageElement>(null);
44
+
45
+ const onImageLoad = (e: SyntheticEvent<HTMLImageElement>) => {
46
+ const im = e.currentTarget;
47
+ setCrop(makeMaxSizeCrop(im.naturalWidth, im.naturalHeight, aspect, initialPosition));
48
+ };
49
+
50
+ useEffect(() => {
51
+ const im = imgRef.current;
52
+ if (!im || !im.complete || !im.naturalWidth || !im.naturalHeight) return;
53
+ setCrop((prev) => {
54
+ const pos = prev ? deriveObjectPosition(prev as PercentCrop) : initialPosition;
55
+ return makeMaxSizeCrop(im.naturalWidth, im.naturalHeight, aspect, pos);
56
+ });
57
+ }, [aspect, initialPosition]);
58
+
59
+ const onApplyClick = () => {
60
+ if (fit === 'contain') {
61
+ onApply({ fit, x: 50, y: 50 });
62
+ return;
63
+ }
64
+ const pos =
65
+ crop && crop.unit === '%' ? deriveObjectPosition(crop as PercentCrop) : { x: 50, y: 50 };
66
+ onApply({ fit, x: round2(pos.x), y: round2(pos.y) });
67
+ };
68
+
69
+ return (
70
+ <Dialog open onOpenChange={(o) => !o && onClose()}>
71
+ <DialogContent className="sm:max-w-2xl">
72
+ <DialogHeader>
73
+ <DialogTitle>{t.inspector.cropDialogTitle}</DialogTitle>
74
+ <DialogDescription>{t.inspector.cropDialogDescription}</DialogDescription>
75
+ </DialogHeader>
76
+ <div className="flex justify-center">
77
+ <ToggleGroup
78
+ type="single"
79
+ value={fit}
80
+ onValueChange={(v) => {
81
+ if (v === 'cover' || v === 'contain') setFit(v);
82
+ }}
83
+ variant="outline"
84
+ size="sm"
85
+ >
86
+ <ToggleGroupItem value="cover" className="text-xs">
87
+ {t.inspector.cropFitCover}
88
+ </ToggleGroupItem>
89
+ <ToggleGroupItem value="contain" className="text-xs">
90
+ {t.inspector.cropFitContain}
91
+ </ToggleGroupItem>
92
+ </ToggleGroup>
93
+ </div>
94
+ <div className="flex h-[420px] w-full items-center justify-center overflow-hidden rounded-md border bg-[repeating-conic-gradient(theme(colors.muted)_0_25%,transparent_0_50%)] bg-[length:12px_12px]">
95
+ {fit === 'cover' ? (
96
+ <ReactCrop
97
+ crop={crop}
98
+ onChange={(_, percentCrop) => setCrop(percentCrop)}
99
+ aspect={aspect}
100
+ keepSelection
101
+ locked
102
+ className="max-h-full"
103
+ >
104
+ <img
105
+ ref={imgRef}
106
+ src={src}
107
+ alt=""
108
+ style={{ maxHeight: 420, maxWidth: '100%' }}
109
+ onLoad={onImageLoad}
110
+ />
111
+ </ReactCrop>
112
+ ) : (
113
+ <img src={src} alt="" className="max-h-full max-w-full object-contain" />
114
+ )}
115
+ </div>
116
+ <DialogFooter>
117
+ <Button variant="outline" onClick={onClose}>
118
+ {t.common.cancel}
119
+ </Button>
120
+ <Button onClick={onApplyClick}>{t.inspector.cropApply}</Button>
121
+ </DialogFooter>
122
+ </DialogContent>
123
+ </Dialog>
124
+ );
125
+ }
126
+
127
+ function makeMaxSizeCrop(
128
+ naturalW: number,
129
+ naturalH: number,
130
+ aspect: number,
131
+ position: { x: number; y: number },
132
+ ): PercentCrop {
133
+ if (naturalW <= 0 || naturalH <= 0) {
134
+ return { unit: '%', x: 0, y: 0, width: 100, height: 100 };
135
+ }
136
+ const sourceAspect = naturalW / naturalH;
137
+ let width = 100;
138
+ let height = 100;
139
+ if (aspect >= sourceAspect) {
140
+ width = 100;
141
+ height = (sourceAspect / aspect) * 100;
142
+ } else {
143
+ height = 100;
144
+ width = (aspect / sourceAspect) * 100;
145
+ }
146
+ const slackX = 100 - width;
147
+ const slackY = 100 - height;
148
+ const x = clamp((position.x / 100) * slackX, 0, slackX);
149
+ const y = clamp((position.y / 100) * slackY, 0, slackY);
150
+ return { unit: '%', x, y, width, height };
151
+ }
152
+
153
+ function deriveObjectPosition(crop: PercentCrop): { x: number; y: number } {
154
+ const slackX = 100 - crop.width;
155
+ const slackY = 100 - crop.height;
156
+ return {
157
+ x: slackX > 0 ? clamp((crop.x / slackX) * 100, 0, 100) : 50,
158
+ y: slackY > 0 ? clamp((crop.y / slackY) * 100, 0, 100) : 50,
159
+ };
160
+ }
161
+
162
+ function clamp(v: number, lo: number, hi: number) {
163
+ return v < lo ? lo : v > hi ? hi : v;
164
+ }
165
+
166
+ function round2(n: number): number {
167
+ return Math.round(n * 100) / 100;
168
+ }