@open-slide/core 1.0.6 → 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.
- package/dist/{build-4wOJF1l4.js → build-DSqSio-T.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-D2y1AXaN.d.ts → config-C7vMYzFD.d.ts} +1 -1
- package/dist/{config-evLWCV1-.js → config-KdiYeWtK.js} +109 -0
- package/dist/{dev-BUr0S-Ij.js → dev-B_GVbr11.js} +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.js +40 -4
- package/dist/{preview-DP_gIphz.js → preview-D_mxhj7w.js} +1 -1
- package/dist/{types-BVvl_xup.d.ts → types-DYgVpIGo.d.ts} +9 -0
- package/dist/vite/index.d.ts +2 -2
- package/dist/vite/index.js +1 -1
- package/package.json +5 -1
- package/src/app/components/inspector/image-crop-dialog.tsx +168 -0
- package/src/app/components/inspector/inspect-overlay.tsx +17 -2
- package/src/app/components/inspector/inspector-panel.tsx +46 -13
- package/src/app/components/inspector/inspector-provider.tsx +83 -1
- package/src/app/components/player.tsx +15 -1
- package/src/app/components/present/use-idle.ts +6 -4
- package/src/app/components/style-panel/design-provider.tsx +13 -0
- package/src/app/components/style-panel/style-panel.tsx +23 -11
- package/src/app/components/thumbnail-rail.tsx +220 -53
- package/src/app/lib/design-presets.ts +94 -0
- package/src/app/routes/presenter.tsx +27 -24
- package/src/app/routes/slide.tsx +53 -1
- package/src/locale/en.ts +9 -0
- package/src/locale/ja.ts +9 -0
- package/src/locale/types.ts +9 -0
- package/src/locale/zh-cn.ts +9 -0
- package/src/locale/zh-tw.ts +9 -0
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-
|
|
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-
|
|
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-
|
|
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) => {
|
|
@@ -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];
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Locale, Plural } from "./types-
|
|
2
|
-
import { OpenSlideConfig } from "./config-
|
|
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
|
|
package/dist/locale/index.d.ts
CHANGED
package/dist/locale/index.js
CHANGED
|
@@ -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: "素材管理僅在開發模式下可用。",
|
|
@@ -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;
|
package/dist/vite/index.d.ts
CHANGED
package/dist/vite/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-slide/core",
|
|
3
|
-
"version": "1.0
|
|
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
|
+
}
|
|
@@ -12,7 +12,7 @@ const FRAME_MORPH_MS = 180;
|
|
|
12
12
|
const LAYOUT_TRACK_MS = PANEL_TRANSITION_MS + FRAME_MORPH_MS;
|
|
13
13
|
|
|
14
14
|
export function InspectOverlay() {
|
|
15
|
-
const { active, slideId, selected, setSelected, cancel } = useInspector();
|
|
15
|
+
const { active, slideId, selected, setSelected, cancel, openCrop } = useInspector();
|
|
16
16
|
const overlayRef = useRef<HTMLDivElement>(null);
|
|
17
17
|
const [hover, setHover] = useState<Highlight | null>(null);
|
|
18
18
|
|
|
@@ -50,15 +50,30 @@ export function InspectOverlay() {
|
|
|
50
50
|
setHover({ hit });
|
|
51
51
|
};
|
|
52
52
|
|
|
53
|
+
const onDblClick = (e: MouseEvent) => {
|
|
54
|
+
if (e.target instanceof Element && e.target.closest('[data-inspector-ui]')) return;
|
|
55
|
+
const el = pickElement(e.clientX, e.clientY);
|
|
56
|
+
if (!el) return;
|
|
57
|
+
const hit = findSlideSource(el, slideId, { hostOnly: true });
|
|
58
|
+
if (!hit) return;
|
|
59
|
+
if (!(hit.anchor instanceof HTMLImageElement)) return;
|
|
60
|
+
e.preventDefault();
|
|
61
|
+
e.stopPropagation();
|
|
62
|
+
setSelected({ line: hit.line, column: hit.column, anchor: hit.anchor });
|
|
63
|
+
openCrop(hit.anchor);
|
|
64
|
+
};
|
|
65
|
+
|
|
53
66
|
window.addEventListener('pointermove', onMove, true);
|
|
54
67
|
window.addEventListener('click', onClick, true);
|
|
68
|
+
window.addEventListener('dblclick', onDblClick, true);
|
|
55
69
|
window.addEventListener('keydown', onKey, true);
|
|
56
70
|
return () => {
|
|
57
71
|
window.removeEventListener('pointermove', onMove, true);
|
|
58
72
|
window.removeEventListener('click', onClick, true);
|
|
73
|
+
window.removeEventListener('dblclick', onDblClick, true);
|
|
59
74
|
window.removeEventListener('keydown', onKey, true);
|
|
60
75
|
};
|
|
61
|
-
}, [active, slideId, setSelected, cancel]);
|
|
76
|
+
}, [active, slideId, setSelected, cancel, openCrop]);
|
|
62
77
|
|
|
63
78
|
return (
|
|
64
79
|
<FrameOverlay
|
|
@@ -202,7 +202,12 @@ export function InspectorPanel() {
|
|
|
202
202
|
<>
|
|
203
203
|
<Separator />
|
|
204
204
|
<Section title={t.inspector.imageSection}>
|
|
205
|
-
<ImageField
|
|
205
|
+
<ImageField
|
|
206
|
+
slideId={slideId}
|
|
207
|
+
src={pinSnapshot.imageSrc}
|
|
208
|
+
anchor={pinSelected.anchor}
|
|
209
|
+
apply={apply}
|
|
210
|
+
/>
|
|
206
211
|
</Section>
|
|
207
212
|
</>
|
|
208
213
|
)}
|
|
@@ -587,14 +592,18 @@ function ColorField({
|
|
|
587
592
|
function ImageField({
|
|
588
593
|
slideId,
|
|
589
594
|
src,
|
|
595
|
+
anchor,
|
|
590
596
|
apply,
|
|
591
597
|
}: {
|
|
592
598
|
slideId: string;
|
|
593
599
|
src: string;
|
|
600
|
+
anchor: HTMLElement;
|
|
594
601
|
apply: (ops: EditOp[]) => void;
|
|
595
602
|
}) {
|
|
596
603
|
const [open, setOpen] = useState(false);
|
|
597
604
|
const t = useLocale();
|
|
605
|
+
const { openCrop } = useInspector();
|
|
606
|
+
const isImage = anchor.tagName === 'IMG';
|
|
598
607
|
return (
|
|
599
608
|
<div className="space-y-2">
|
|
600
609
|
<div className="flex items-center gap-3">
|
|
@@ -609,16 +618,29 @@ function ImageField({
|
|
|
609
618
|
}}
|
|
610
619
|
/>
|
|
611
620
|
</div>
|
|
612
|
-
<
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
621
|
+
<div className="flex flex-1 gap-2">
|
|
622
|
+
<Button
|
|
623
|
+
type="button"
|
|
624
|
+
variant="outline"
|
|
625
|
+
size="sm"
|
|
626
|
+
className="flex-1"
|
|
627
|
+
onClick={() => setOpen(true)}
|
|
628
|
+
>
|
|
629
|
+
<ImageIcon className="size-3.5" />
|
|
630
|
+
{t.inspector.replace}
|
|
631
|
+
</Button>
|
|
632
|
+
{isImage && (
|
|
633
|
+
<Button
|
|
634
|
+
type="button"
|
|
635
|
+
variant="outline"
|
|
636
|
+
size="sm"
|
|
637
|
+
className="flex-1"
|
|
638
|
+
onClick={() => openCrop(anchor as HTMLImageElement)}
|
|
639
|
+
>
|
|
640
|
+
{t.inspector.crop}
|
|
641
|
+
</Button>
|
|
642
|
+
)}
|
|
643
|
+
</div>
|
|
622
644
|
</div>
|
|
623
645
|
{open && (
|
|
624
646
|
<AssetPickerDialog
|
|
@@ -626,14 +648,25 @@ function ImageField({
|
|
|
626
648
|
onClose={() => setOpen(false)}
|
|
627
649
|
onPick={(asset) => {
|
|
628
650
|
setOpen(false);
|
|
629
|
-
|
|
651
|
+
const ops: EditOp[] = [
|
|
630
652
|
{
|
|
631
653
|
kind: 'set-attr-asset',
|
|
632
654
|
attr: 'src',
|
|
633
655
|
assetPath: `./assets/${asset.name}`,
|
|
634
656
|
previewUrl: asset.url,
|
|
635
657
|
},
|
|
636
|
-
]
|
|
658
|
+
];
|
|
659
|
+
if (isImage) {
|
|
660
|
+
const cs = window.getComputedStyle(anchor);
|
|
661
|
+
if (cs.objectFit !== 'cover' && cs.objectFit !== 'contain') {
|
|
662
|
+
ops.push({ kind: 'set-style', key: 'objectFit', value: 'cover' });
|
|
663
|
+
}
|
|
664
|
+
const op = cs.objectPosition.trim();
|
|
665
|
+
if (!op || op === '0% 0%' || op === 'auto') {
|
|
666
|
+
ops.push({ kind: 'set-style', key: 'objectPosition', value: '50% 50%' });
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
apply(ops);
|
|
637
670
|
}}
|
|
638
671
|
/>
|
|
639
672
|
)}
|