diff --git a/README.md b/README.md index 8ae9534..1686f77 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,18 @@ - **2種類の手帳** - **プロフィール帳式** — 最初からかわいい飾り枠つき。工程フローや自由帳ページを作れる - **ノート式** — 白紙のキャンバスに、テキスト・写真・シールを自由に配置してコラージュ -- **工程フロー** — 工程をチェックリストで並べる。完了するとシールが弾ける演出。**if分岐**(はい/いいえ)で条件分けもでき、分岐の中にさらにif分岐を**入れ子**にして深い条件分けも作れる -- **フリーコラージュ** — テキストカード・写真・シールをドラッグ/拡大/回転で自由配置。カード同士を**つなぎ線**で結べる -- **シール** — 星・花・ハート・リボン・きらめきの5種類。好きな大きさ・角度で貼れる +- **工程フロー** — 工程をチェックリストで並べる。完了するとシールが弾ける演出と、上部の進み具合(`3/7`)表示。**if分岐**(はい/いいえ)で条件分けもでき、分岐の中にさらにif分岐を**入れ子**にして深い条件分けも作れる。分岐の中の工程も▲▼で並べ替えられる +- **フリーコラージュ** — テキストカード・写真・シールをドラッグ/拡大/回転で自由配置。カード同士を**つなぎ線**で結べる(線そのものも動かす/伸ばす/回すことができ、`↺`で自動配置に戻せる) +- **コピー** — 手帳まるごと(📄)と、ページ単位(タブの📄)の複製 +- **シール** — 星・花・ハート・リボン・きらめき・くも・三日月・王冠・ちょうちょ・にくきゅう・宝石の11種類。好きな大きさ・角度で貼れる - **飾り枠** — シンプル/オーロラ滲み/星屑ちらし/リボン留め - **用紙** — ノート式は横罫・方眼・ドット・無地+9色の用紙色 - **きらめき** — 画面いっぱいに星がまたたく環境演出のオン/オフ - **画像で保存** — 盤面をPNGにして共有・保存(📸) -- **バックアップ** — JSON書き出し/読み込み。**Web版オリジナルのバックアップJSONもそのまま読み込めます**(写真も含めて相互互換) -- **元に戻す** — 直前の操作をひとつ戻す(最大15手) +- **バックアップ** — JSON書き出し/読み込み(写真も丸ごと1ファイルに入ります) +- **元に戻す** — 直前の操作をひとつ戻す(最大15手)。手帳・ページの削除や、バックアップの読み込みも戻せます + +端末の「視差効果を減らす」設定がオンのときは、きらめきと紙吹雪のアニメーションを止めます。 ## APKの入手 @@ -29,4 +32,35 @@ - 作った手帳・ページ・シール・写真は、**すべて端末内だけ**に保存されます(外部のサーバーには一切送られません) - 写真もJSON内にそのまま埋め込んで保存するので、バックアップ1ファイルで丸ごと持ち運べます - アプリをアンインストールするとデータは消えます。**大事な記録は下部ツールバーの「💾 書き出し」から、ときどきJSONファイルに書き出しておくことを強くおすすめします** - - 書き出したJSONは「📂 読み込み」から復元できます(今のデータは上書きされるので注意) + - 書き出したJSONは「⋯ → 📂 バックアップを読み込む」から復元できます(今のデータは置きかわりますが、直後なら「↩️ 元に戻す」で戻せます) + +### バックアップの形式について(0.3.0で変更) + +0.3.0 から、書き出すJSONは形式名とバージョンを持つ「封筒」に包まれた独自形式になりました。 + +```json +{ + "format": "kawaii-deconote-android", + "formatVersion": 1, + "appVersion": "0.3.0", + "exportedAt": "2026-07-25T00:00:00.000Z", + "state": { "activeNotebookId": null, "notebooks": [] } +} +``` + +これにともない、**Web版オリジナルが書き出したJSONの読み込み対応は終了しました**。Android版は独自の保存形式として進めます。0.2.x以前のAndroid版が書き出したJSONも同じ理由で読み込めないので、古いバックアップが手元にある場合は0.2.xで復元してから0.3.0で書き出し直してください。 + +## 開発 + +```sh +npm ci +npm run typecheck # TypeScript +npm run lint # oxlint +npm run test # vitest(純粋ロジックはカバレッジ100%を維持) +npm run format # prettier +npm run android # 実機・エミュレータで起動 +``` + +- `src/lib` … 状態を持たない純粋ロジック(モデル・正規化・複製・色・バックアップ形式)。ここだけがテスト対象です +- `src/state` … zustand ストア(保存・巻き戻し・UIの一時状態) +- `src/components` … 画面パーツ。色・字・余白は `src/lib/theme.ts` のトークンから取ります diff --git a/app.json b/app.json index 2099e22..5547d53 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "かわいくデコれる手帳", "slug": "kawaii-deconote", - "version": "0.2.1", + "version": "0.3.0", "orientation": "portrait", "icon": "./assets/icon.png", "scheme": "kawaiideconote", @@ -15,7 +15,7 @@ }, "android": { "package": "io.github.pppzet.kawaiideconote", - "versionCode": 3, + "versionCode": 4, "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", "backgroundColor": "#F3E9F7" diff --git a/package.json b/package.json index 3eb2ed5..ffe6acd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kawaii-deconote", - "version": "0.2.1", + "version": "0.3.0", "main": "expo-router/entry", "scripts": { "start": "expo start", diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 3c57617..d554c44 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -17,6 +17,8 @@ import { recoverPendingPhotoAsDataUrl } from "@/lib/files"; import { colors } from "@/lib/theme"; import { selectCurrentNotebook, selectCurrentPage, useNotebooks } from "@/state/notebooks"; +const AUTOSAVE_MS = 8000; + void SplashScreen.preventAutoHideAsync(); export default function RootLayout() { @@ -35,6 +37,7 @@ export default function RootLayout() { setInitialized(true); }, []); + // アプリが前面から外れる瞬間と、一定時間ごとに保存する。 useEffect(() => { const sub = AppState.addEventListener("change", (state) => { if (state !== "active") useNotebooks.getState().flushPending(); @@ -43,10 +46,11 @@ export default function RootLayout() { }, []); useEffect(() => { - const id = setInterval(() => useNotebooks.getState().flushPending(), 8000); + const id = setInterval(() => useNotebooks.getState().flushPending(), AUTOSAVE_MS); return () => clearInterval(id); }, []); + // 写真を選んでいる途中でAndroidにアプリを落とされた場合、戻ってきたときに拾い直す。 useEffect(() => { const recover = async () => { const before = useNotebooks.getState(); @@ -73,7 +77,8 @@ export default function RootLayout() { return ( - + {/* 端末の「視差効果を減らす」設定をそのまま尊重する */} + diff --git a/src/app/index.tsx b/src/app/index.tsx index b99165e..becfa79 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -5,11 +5,14 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { CoverGrid } from "@/components/cover/CoverGrid"; import { CreatePanel } from "@/components/cover/CreatePanel"; import { PromptModal } from "@/components/ui/PromptModal"; +import { tapFeedback } from "@/lib/haptics"; import { notebookDisplayName } from "@/lib/model"; -import { colors, fonts } from "@/lib/theme"; +import { colors, radii, space, text } from "@/lib/theme"; +import { MAX_NOTEBOOK_NAME } from "@/lib/types"; import type { NotebookType } from "@/lib/types"; import { useImportBackup } from "@/state/backup"; import { useNotebooks } from "@/state/notebooks"; +import { useUi } from "@/state/ui"; export default function CoverScreen() { const router = useRouter(); @@ -17,15 +20,23 @@ export default function CoverScreen() { const notebooks = useNotebooks((s) => s.doc.notebooks); const storageOk = useNotebooks((s) => s.storageOk); const storageError = useNotebooks((s) => s.storageError); + const canUndo = useNotebooks((s) => s.canUndo); const createNotebook = useNotebooks((s) => s.createNotebook); + const duplicateNotebook = useNotebooks((s) => s.duplicateNotebook); const deleteNotebook = useNotebooks((s) => s.deleteNotebook); const renameNotebook = useNotebooks((s) => s.renameNotebook); const recheckStorage = useNotebooks((s) => s.recheckStorage); + const showToast = useUi((s) => s.showToast); const [creating, setCreating] = useState(false); const [renameId, setRenameId] = useState(null); - const open = (id: string) => router.push({ pathname: "/notebook/[id]", params: { id } }); + // 画面を出す前に「開いている手帳」を確定させる。編集画面の初回描画で + // 一瞬「手帳が見つかりませんでした」が出るのを防ぐため。 + const open = (id: string) => { + useNotebooks.getState().openNotebook(id); + router.push({ pathname: "/notebook/[id]", params: { id } }); + }; const onCreate = (type: NotebookType, name: string, color: string) => { const id = createNotebook(type, name, color); @@ -33,26 +44,51 @@ export default function CoverScreen() { open(id); }; + const onDuplicate = (id: string) => { + if (duplicateNotebook(id) === null) return; + tapFeedback(); + showToast("手帳をコピーしたよ📔✨"); + }; + const onDelete = (id: string) => { - Alert.alert("手帳を削除", "この手帳を削除します。中のページも全部消えます。よろしいですか?", [ - { text: "やめる", style: "cancel" }, - { text: "削除", style: "destructive", onPress: () => deleteNotebook(id) }, - ]); + const nb = notebooks.find((n) => n.id === id); + if (!nb) return; + Alert.alert( + "手帳を削除", + `「${notebookDisplayName(nb)}」と中の${nb.pages.length}ページを削除します。消したあとでも「元に戻す」で戻せます。`, + [ + { text: "やめる", style: "cancel" }, + { + text: "削除", + style: "destructive", + onPress: () => { + deleteNotebook(id); + showToast("手帳を削除しました。元に戻せます🗑️"); + }, + }, + ], + ); + }; + + const onUndo = () => { + const ok = useNotebooks.getState().undo(); + showToast(ok ? "↩️ ひとつ前に戻しました" : "⚠️ まだ保存中で戻せません…もう一度どうぞ"); }; const onImport = useImportBackup(); const renameTarget = notebooks.find((n) => n.id === renameId); + const subtitle = notebooks.length === 0 ? "まずは1冊、表紙から選んでつくろう" : `${notebooks.length}冊の手帳`; return ( <> かわいくデコれる手帳 🌸✨ - お気に入りの手帳を選んでね + {subtitle} {!storageOk ? ( @@ -62,20 +98,34 @@ export default function CoverScreen() { {storageError ? {storageError} : null} - + 🔧 もう一度確認する - + 📂 バックアップから復元 ) : null} + {/* 一覧側でも削除・コピーを取り消せるようにしておく(編集画面のツールバーは + ここからは届かないため) */} + {canUndo ? ( + [styles.undo, pressed && styles.undoPressed]} + onPress={onUndo} + accessibilityRole="button" + accessibilityLabel="ひとつ前に戻す" + > + ↩️ さっきの操作を元に戻す + + ) : null} + setRenameId(id)} + onDuplicate={onDuplicate} onDelete={onDelete} onAdd={() => setCreating(true)} /> @@ -88,7 +138,7 @@ export default function CoverScreen() { title="手帳の名前を入力してね📔" initialValue={renameTarget ? notebookDisplayName(renameTarget) : ""} placeholder="手帳のなまえ" - maxLength={30} + maxLength={MAX_NOTEBOOK_NAME} onCancel={() => setRenameId(null)} onSubmit={(name) => { if (renameId) renameNotebook(renameId, name); @@ -101,7 +151,7 @@ export default function CoverScreen() { const styles = StyleSheet.create({ content: { - paddingHorizontal: 16, + paddingHorizontal: space.lg, paddingBottom: 40, maxWidth: 560, width: "100%", @@ -109,40 +159,35 @@ const styles = StyleSheet.create({ }, header: { alignItems: "center", - marginBottom: 18, + marginBottom: space.lg, }, title: { - fontFamily: fonts.display, - fontSize: 24, + ...text.displayL, color: colors.plum, - marginBottom: 4, + marginBottom: space.xs, textAlign: "center", }, subtitle: { - fontFamily: fonts.body, - fontSize: 12, + ...text.label, color: colors.ink, opacity: 0.6, }, warning: { - backgroundColor: "#fff3e0", + backgroundColor: colors.warnBg, borderWidth: 1.5, - borderColor: "#f0b86e", + borderColor: colors.warnBorder, borderRadius: 14, - padding: 12, - marginBottom: 14, + padding: space.md, + marginBottom: space.md, }, warningText: { - fontFamily: fonts.body, - fontSize: 12, - color: "#7a4a12", + ...text.label, + color: colors.warnInk, textAlign: "center", - lineHeight: 18, }, warningDetail: { - fontFamily: fonts.body, - fontSize: 10, - color: "#7a4a12", + ...text.micro, + color: colors.warnInk, opacity: 0.8, marginTop: 6, textAlign: "center", @@ -151,19 +196,35 @@ const styles = StyleSheet.create({ flexDirection: "row", flexWrap: "wrap", justifyContent: "center", - gap: 8, - marginTop: 8, + gap: space.sm, + marginTop: space.sm, }, recheck: { borderWidth: 1, - borderColor: "#d99a4a", - borderRadius: 10, - paddingVertical: 4, - paddingHorizontal: 12, + borderColor: "#D99A4A", + borderRadius: radii.small, + paddingVertical: 5, + paddingHorizontal: space.md, }, recheckText: { - fontFamily: fonts.body, - fontSize: 11, - color: "#7a4a12", + ...text.caption, + color: colors.warnInk, + }, + undo: { + alignSelf: "center", + marginBottom: space.md, + paddingVertical: 7, + paddingHorizontal: space.lg, + borderRadius: radii.pill, + backgroundColor: colors.veilStrong, + borderWidth: 1.5, + borderColor: colors.hairline, + }, + undoPressed: { + opacity: 0.75, + }, + undoText: { + ...text.caption, + color: colors.plum, }, }); diff --git a/src/app/notebook/[id].tsx b/src/app/notebook/[id].tsx index 4dce7a2..2e7b845 100644 --- a/src/app/notebook/[id].tsx +++ b/src/app/notebook/[id].tsx @@ -1,5 +1,5 @@ import { useLocalSearchParams, useRouter } from "expo-router"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Alert, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from "react-native"; import type { LayoutChangeEvent } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -15,12 +15,19 @@ import { NotebookSection } from "@/components/notebook/NotebookSection"; import { exportBackup } from "@/lib/backup"; import { captureAndShare } from "@/lib/capture"; import { pickPhotoAsDataUrl } from "@/lib/files"; -import { colors, fonts, TRAY_BASE_HEIGHT } from "@/lib/theme"; +import { randomBetween } from "@/lib/format"; +import { tapFeedback } from "@/lib/haptics"; +import { notebookDisplayName, pageDisplayTitle } from "@/lib/model"; +import { chic, colors, fonts, radii, shadows, space, text, TRAY_BASE_HEIGHT } from "@/lib/theme"; +import { MAX_PAGE_TITLE } from "@/lib/types"; import type { StickerType } from "@/lib/types"; import { useImportBackup } from "@/state/backup"; -import { selectCurrentNotebook, selectCurrentPage, useNotebooks } from "@/state/notebooks"; +import { activePageOf, selectNotebookById, useNotebooks } from "@/state/notebooks"; import { useUi } from "@/state/ui"; +// 選択中のシールを画面から消してから撮るための待ち時間。 +const CAPTURE_SETTLE_MS = 70; + export default function NotebookEditor() { const params = useLocalSearchParams<{ id: string }>(); const id = Array.isArray(params.id) ? params.id[0] : params.id; @@ -29,13 +36,17 @@ export default function NotebookEditor() { const boardRef = useRef(null); const [boardWidth, setBoardWidth] = useState(320); - const notebook = useNotebooks(selectCurrentNotebook); - const page = useNotebooks(selectCurrentPage); + // ルートの id から直接引く。ストアの activeNotebookId を待たないので、 + // 画面に入った1フレーム目で「手帳が見つかりません」が出ることがない。 + const selectNotebook = useMemo(() => selectNotebookById(id), [id]); + const notebook = useNotebooks(selectNotebook); + const page = activePageOf(notebook); const canUndo = useNotebooks((s) => s.canUndo); const storageOk = useNotebooks((s) => s.storageOk); const setActivePage = useNotebooks((s) => s.setActivePage); const deletePage = useNotebooks((s) => s.deletePage); + const duplicatePage = useNotebooks((s) => s.duplicatePage); const addPage = useNotebooks((s) => s.addPage); const setPageTitle = useNotebooks((s) => s.setPageTitle); const setFrame = useNotebooks((s) => s.setFrame); @@ -46,7 +57,6 @@ export default function NotebookEditor() { const addSticker = useNotebooks((s) => s.addSticker); const addShape = useNotebooks((s) => s.addShape); const addPhotoTo = useNotebooks((s) => s.addPhotoTo); - const undo = useNotebooks((s) => s.undo); const connectMode = useUi((s) => s.connectMode); const select = useUi((s) => s.select); @@ -75,29 +85,41 @@ export default function NotebookEditor() { if (!notebook || !page) { return ( - 手帳が見つかりませんでした - router.replace("/")}> - 📚 手帳一覧に戻る + この手帳は見つかりませんでした + router.replace("/")} accessibilityRole="button"> + 📚 手帳一覧に戻る ); } const isNoteStyle = notebook.type === "notestyle"; - const rand = (max: number) => Math.random() * Math.max(0, max); - const stickerPos = () => ({ x: Math.round(30 + rand(boardWidth - 90)), y: Math.round(40 + rand(140)) }); - const shapePos = () => ({ x: Math.round(16 + rand(Math.max(40, boardWidth - 160))), y: Math.round(16 + rand(160)) }); + + // 「だいたいこのへん」に置く。毎回きっちり同じ場所だと重なって見えないため。 + const stickerPos = () => ({ + x: Math.round(randomBetween(30, Math.max(40, boardWidth - 60))), + y: Math.round(randomBetween(40, 180)), + }); + const shapePos = () => ({ + x: Math.round(randomBetween(16, Math.max(56, boardWidth - 160))), + y: Math.round(randomBetween(16, 176)), + }); const onBoardLayout = (e: LayoutChangeEvent) => setBoardWidth(e.nativeEvent.layout.width); const onAddSticker = (type: StickerType) => { const p = stickerPos(); addSticker(type, p.x, p.y); + tapFeedback(); }; + const onAddText = () => { const p = shapePos(); - addShape(p.x, p.y); + const newId = addShape(p.x, p.y); + select(newId); + useUi.getState().setFocus(newId); }; + const onAddPhoto = async () => { const targetNotebook = notebook.id; const targetPage = page.id; @@ -106,23 +128,27 @@ export default function NotebookEditor() { const dataUrl = await pickPhotoAsDataUrl(); if (!dataUrl) return; addPhotoTo(targetNotebook, targetPage, dataUrl, p.x, p.y); + tapFeedback(); } catch { showToast("⚠️ 写真の読み込みに失敗しました"); } }; + const onToggleConnect = () => { const willEnable = !connectMode; toggleConnectMode(); if (willEnable) showToast("🔗 つなぎたいテキストを2つ順にタップしてね"); }; + const onUndo = () => { - const ok = undo(); + const ok = useNotebooks.getState().undo(); useUi.getState().resetBoardUi(); showToast(ok ? "↩️ ひとつ前に戻しました" : "⚠️ まだ保存中で戻せません…もう一度どうぞ"); }; + const onScreenshot = async () => { select(null); - await new Promise((r) => setTimeout(r, 70)); + await new Promise((r) => setTimeout(r, CAPTURE_SETTLE_MS)); try { await captureAndShare(boardRef, page.title || "techo"); showToast("画像を保存しました📸"); @@ -130,6 +156,7 @@ export default function NotebookEditor() { showToast("スクショに失敗しました…💦"); } }; + const onExport = async () => { try { await exportBackup(useNotebooks.getState().doc); @@ -138,28 +165,53 @@ export default function NotebookEditor() { showToast("書き出しに失敗しました…"); } }; + const onReset = () => { - Alert.alert("ページを消去", "このページの中身を全部消します。よろしいですか?", [ - { text: "やめる", style: "cancel" }, - { - text: "消去", - style: "destructive", - onPress: () => { - resetPage(); - useUi.getState().resetBoardUi(); + Alert.alert( + "ページを消去", + `「${pageDisplayTitle(page)}」の中身を全部消します。消したあとでも「元に戻す」で戻せます。`, + [ + { text: "やめる", style: "cancel" }, + { + text: "消去", + style: "destructive", + onPress: () => { + resetPage(); + useUi.getState().resetBoardUi(); + showToast("ページを空にしました。元に戻せます🗑️"); + }, }, - }, - ]); + ], + ); + }; + + const onDuplicatePage = (pageId: string) => { + if (!duplicatePage(pageId)) return; + tapFeedback(); + showToast("ページをコピーしたよ📄✨"); }; + const onDeletePage = (pageId: string) => { if (notebook.pages.length <= 1) { showToast("最後のページは消せません🌸"); return; } - Alert.alert("ページを削除", "このページを削除します。中身も全部消えます。よろしいですか?", [ - { text: "やめる", style: "cancel" }, - { text: "削除", style: "destructive", onPress: () => deletePage(pageId) }, - ]); + const target = notebook.pages.find((p) => p.id === pageId); + Alert.alert( + "ページを削除", + `「${target ? pageDisplayTitle(target) : "このページ"}」を中身ごと削除します。消したあとでも「元に戻す」で戻せます。`, + [ + { text: "やめる", style: "cancel" }, + { + text: "削除", + style: "destructive", + onPress: () => { + deletePage(pageId); + showToast("ページを削除しました。元に戻せます🗑️"); + }, + }, + ], + ); }; return ( @@ -167,22 +219,34 @@ export default function NotebookEditor() { - - 📚 手帳一覧に戻る - + + + 📚 一覧 + + + {notebookDisplayName(notebook)} + + - + @@ -215,9 +279,13 @@ export default function NotebookEditor() { - {} {!storageOk ? ( - + ⚠️ 保存できていません。タップして「💾書き出し」でバックアップを ) : null} @@ -227,7 +295,6 @@ export default function NotebookEditor() { sparkleOn={page.sparkleOn} canUndo={canUndo} connectMode={connectMode} - chic={isNoteStyle} onAddSticker={onAddSticker} onAddText={onAddText} onAddPhoto={onAddPhoto} @@ -245,87 +312,102 @@ export default function NotebookEditor() { const styles = StyleSheet.create({ content: { - paddingHorizontal: 14, + paddingHorizontal: space.md + 2, maxWidth: 620, width: "100%", alignSelf: "center", }, + topRow: { + flexDirection: "row", + alignItems: "center", + gap: space.sm, + paddingVertical: space.xs, + }, back: { - alignSelf: "center", - paddingVertical: 6, + paddingVertical: space.xs, + paddingRight: space.xs, }, - backBtn: { - fontFamily: fonts.body, - fontSize: 12, + backText: { + ...text.label, color: colors.plum, }, - backBtnChic: { - color: "#777", + backTextChic: { + color: chic.inkSoft, + }, + notebookName: { + flex: 1, + ...text.caption, + color: colors.plum, + opacity: 0.7, + textAlign: "right", + }, + notebookNameChic: { + color: chic.inkSoft, }, titleInput: { alignSelf: "center", width: "92%", textAlign: "center", + // TextInput は lineHeight を指定するとAndroidで縦位置がずれやすいので、 + // ここだけは字面サイズだけを指定する。 fontFamily: fonts.display, - fontSize: 19, + fontSize: 20, color: colors.ink, - backgroundColor: "rgba(255,255,255,0.55)", + backgroundColor: colors.veil, borderBottomWidth: 2, - borderBottomColor: "rgba(155,130,180,0.4)", + borderBottomColor: colors.dashed, borderStyle: "dashed", borderRadius: 8, paddingVertical: 6, - paddingHorizontal: 10, - marginTop: 8, + paddingHorizontal: space.sm, + marginTop: space.sm, marginBottom: 6, }, titleInputChic: { fontFamily: fonts.body, fontSize: 16, - color: "#333", + color: chic.ink, backgroundColor: "transparent", - borderBottomColor: "#ccc", + borderBottomColor: "#CCCCCC", }, controls: { - marginTop: 10, - paddingHorizontal: 4, + marginTop: space.sm, + paddingHorizontal: space.xs, }, capture: { - marginTop: 4, + marginTop: space.xs, }, notestyleBoard: { borderRadius: 8, - padding: 12, + padding: space.md, minHeight: 320, overflow: "hidden", - boxShadow: "0 1px 4px rgba(0,0,0,0.08)", + boxShadow: shadows.chicBoard, }, missing: { flex: 1, alignItems: "center", - gap: 16, + gap: space.lg, }, missingText: { - fontFamily: fonts.body, - fontSize: 14, + ...text.bodyL, color: colors.ink, }, saveWarn: { position: "absolute", - left: 12, - right: 12, - backgroundColor: "#fff3e0", + left: space.md, + right: space.md, + backgroundColor: colors.warnBg, borderWidth: 1.5, - borderColor: "#f0b86e", - borderRadius: 12, - paddingVertical: 8, - paddingHorizontal: 12, + borderColor: colors.warnBorder, + borderRadius: radii.small, + paddingVertical: space.sm, + paddingHorizontal: space.md, zIndex: 50, }, saveWarnText: { - fontFamily: fonts.body, - fontSize: 12, - color: "#7a4a12", + ...text.label, + color: colors.warnInk, textAlign: "center", }, }); diff --git a/src/components/board/BoardFrame.tsx b/src/components/board/BoardFrame.tsx index c42a43f..01c5842 100644 --- a/src/components/board/BoardFrame.tsx +++ b/src/components/board/BoardFrame.tsx @@ -1,20 +1,24 @@ import { useState, type ReactNode } from "react"; import { Pressable, StyleSheet, View } from "react-native"; +import type { LayoutChangeEvent, ViewStyle } from "react-native"; import Svg, { Circle, Path } from "react-native-svg"; -import { colors, radii } from "@/lib/theme"; +import { colors, radii, shadows, space } from "@/lib/theme"; import { useUi } from "@/state/ui"; import type { Page } from "@/lib/types"; import { StickerShape } from "@/components/ui/StickerShape"; import { StickerItem } from "./StickerItem"; -function Corner({ frame, style }: { frame: Page["frame"]; style: object }) { +const BOARD_MIN_HEIGHT = 260; +const STICKER_MARGIN = 20; + +function Corner({ frame, style }: { frame: Page["frame"]; style: ViewStyle }) { if (frame !== "star" && frame !== "aurora") return null; return ( {frame === "star" ? ( - + ) : ( - + )} ); @@ -28,15 +32,15 @@ function RibbonTag() { d="M21 6 C14 2, 6 6, 8 14 C10 20, 21 24, 21 24 C21 24, 32 20, 34 14 C36 6, 28 2, 21 6 Z" fill={colors.rose} /> - + ); } -const FRAME_STYLE: Record = { - plain: { borderWidth: 2, borderColor: "rgba(155,130,180,0.35)", borderStyle: "dashed" }, - aurora: { boxShadow: "0 0 40px rgba(201,182,228,0.35)", borderWidth: 2, borderColor: "rgba(255,255,255,0.6)" }, +const FRAME_STYLE: Record = { + plain: { borderWidth: 2, borderColor: colors.line, borderStyle: "dashed" }, + aurora: { boxShadow: shadows.glow, borderWidth: 2, borderColor: "rgba(255,255,255,0.6)" }, star: { borderWidth: 2, borderColor: "rgba(244,213,141,0.7)", borderStyle: "dotted" }, ribbon: { borderWidth: 2, borderColor: "rgba(232,180,188,0.55)" }, }; @@ -44,14 +48,21 @@ const FRAME_STYLE: Record = { export function BoardFrame({ page, children }: { page: Page; children: ReactNode }) { const selectedId = useUi((s) => s.selectedId); const select = useUi((s) => s.select); - const [contentW, setContentW] = useState(undefined); + const [layerWidth, setLayerWidth] = useState(undefined); - const stickerExtent = page.stickers.reduce((m, s) => Math.max(m, s.y + s.size + 20), 0); + // シールを下へ引っぱったぶんだけ盤面を伸ばす(Web版の fitBoardForStickers 相当)。 + const stickerExtent = page.stickers.reduce((m, s) => Math.max(m, s.y + s.size + STICKER_MARGIN), 0); + const onLayerLayout = (e: LayoutChangeEvent) => setLayerWidth(e.nativeEvent.layout.width); return ( - - select(null)} /> + + select(null)} + accessibilityRole="button" + accessibilityLabel="選択を解除" + /> {page.frame === "ribbon" ? : null} @@ -59,23 +70,22 @@ export function BoardFrame({ page, children }: { page: Page; children: ReactNode - 0 ? { minHeight: stickerExtent } : null]} - onLayout={(e) => setContentW(e.nativeEvent.layout.width)} - > - {children} - + {children} - {page.stickers.map((sticker) => ( - select(sticker.id)} - boundsWidth={contentW} - /> - ))} + {/* シールは盤面いっぱいのレイヤーに置く(Web版の .sticker-layer と同じ座標系)。 + box-none にしてあるので、シールの無いところのタップは下の中身に届く。 */} + + {page.stickers.map((sticker) => ( + select(sticker.id)} + boundsWidth={layerWidth} + /> + ))} + ); @@ -83,15 +93,14 @@ export function BoardFrame({ page, children }: { page: Page; children: ReactNode const styles = StyleSheet.create({ wrap: { - marginTop: 18, + marginTop: space.lg, }, board: { - backgroundColor: colors.panel, + backgroundColor: colors.veil, borderRadius: radii.board, paddingTop: 34, - paddingHorizontal: 22, + paddingHorizontal: space.xl, paddingBottom: 30, - minHeight: 260, }, content: { position: "relative", diff --git a/src/components/board/BoardTabs.tsx b/src/components/board/BoardTabs.tsx index 9657dbe..46e217d 100644 --- a/src/components/board/BoardTabs.tsx +++ b/src/components/board/BoardTabs.tsx @@ -1,16 +1,24 @@ -import { Pressable, ScrollView, StyleSheet, Text } from "react-native"; -import { colors, fonts } from "@/lib/theme"; +import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; import { pageDisplayTitle } from "@/lib/model"; -import type { Notebook, PageType } from "@/lib/types"; +import { chic as chicTheme, colors, radii, shadows, space, text } from "@/lib/theme"; +import type { Notebook, Page, PageType } from "@/lib/types"; +import { IconButton } from "@/components/ui/kit"; + +function tabIcon(notebook: Notebook, page: Page): string { + if (notebook.type === "notestyle") return "📄"; + return page.type === "notebook" ? "📖" : "📋"; +} export function BoardTabs({ notebook, onSelect, + onDuplicate, onDelete, onAddPage, }: { notebook: Notebook; onSelect: (id: string) => void; + onDuplicate: (id: string) => void; onDelete: (id: string) => void; onAddPage: (type: PageType) => void; }) { @@ -25,31 +33,81 @@ export function BoardTabs({ > {notebook.pages.map((p) => { const active = p.id === notebook.activePageId; - const icon = isNoteStyle ? "📄" : p.type === "notebook" ? "📖" : "📋"; + const title = pageDisplayTitle(p); return ( - onSelect(p.id)}> - - {icon} {pageDisplayTitle(p)} - + // タブの外枠は押せない容れ物にして、ページ切り替えとコピー・削除を + // 兄弟に並べる。押せる要素を入れ子にすると TalkBack が外側だけを + // ひとかたまりで拾い、中のボタンに降りられなくなるため。 + + {/* 見出しインデックスの「つまみ」。開いているページだけ色が乗る */} + {active && !isNoteStyle ? ( + + ) : null} + + onSelect(p.id)} + accessibilityRole="tab" + accessibilityState={{ selected: active }} + accessibilityLabel={`${title}のページ`} + > + + {tabIcon(notebook, p)} {title} + + + {active ? ( - onDelete(p.id)} hitSlop={8}> - - + + onDuplicate(p.id)} + /> + onDelete(p.id)} + /> + ) : null} - + ); })} {isNoteStyle ? ( - onAddPage("flowchart")}> - + ページを追加 + onAddPage("flowchart")} + accessibilityRole="button" + > + + ページを追加 ) : ( <> - onAddPage("flowchart")}> + onAddPage("flowchart")} accessibilityRole="button"> +📋 工程 - onAddPage("notebook")}> + onAddPage("notebook")} accessibilityRole="button"> +📖 自由帳 @@ -60,61 +118,108 @@ export function BoardTabs({ const styles = StyleSheet.create({ row: { - gap: 8, - paddingHorizontal: 4, - paddingVertical: 10, - alignItems: "center", + gap: space.sm, + paddingHorizontal: space.xs, + paddingTop: space.md, + paddingBottom: space.xs, + alignItems: "flex-end", }, tab: { - maxWidth: 170, - paddingVertical: 7, - paddingHorizontal: 14, - borderRadius: 14, - backgroundColor: colors.panelSoft, + maxWidth: 230, + minHeight: 34, + paddingRight: space.sm, + borderTopLeftRadius: 14, + borderTopRightRadius: 14, + borderBottomLeftRadius: 4, + borderBottomRightRadius: 4, + backgroundColor: colors.veilWeak, borderWidth: 1.5, borderColor: "transparent", flexDirection: "row", alignItems: "center", + overflow: "hidden", }, tabActive: { backgroundColor: colors.white, borderColor: colors.lavender, - boxShadow: "0 2px 8px rgba(155,130,180,0.2)", + paddingTop: 3, + boxShadow: shadows.chip, + }, + // ページ切り替えの当たり判定。文字のまわり全部を押せるようにする。 + tabLabel: { + flexShrink: 1, + paddingVertical: 7, + paddingLeft: space.md, + paddingRight: 6, + }, + // 開いているページの上端に走る色帯=インデックスのつまみ。 + tabMark: { + position: "absolute", + left: 0, + right: 0, + top: 0, + height: 4, + }, + tabChic: { + backgroundColor: "transparent", + // React Native では borderRadius より個別の角指定が優先されるので、 + // 平らにしたいときは4隅ぶんを明示的に 0 にする。 + borderTopLeftRadius: 0, + borderTopRightRadius: 0, + borderBottomLeftRadius: 0, + borderBottomRightRadius: 0, + borderWidth: 0, + borderBottomWidth: 2, + borderBottomColor: "transparent", + paddingRight: space.xs, + }, + tabChicActive: { + borderBottomColor: chicTheme.rule, }, tabText: { - fontFamily: fonts.body, - fontSize: 12, + ...text.label, color: colors.ink, + flexShrink: 1, }, tabTextActive: { - fontFamily: fonts.bodyBold, - }, - tabDel: { - marginLeft: 8, - width: 18, - height: 18, - borderRadius: 9, - backgroundColor: colors.rose, - alignItems: "center", - justifyContent: "center", + ...text.labelBold, + color: colors.ink, + }, + tabTextChic: { + color: chicTheme.inkSoft, }, - tabDelText: { - color: colors.white, - fontSize: 10, - lineHeight: 12, + tabTextChicActive: { + color: "#222222", + }, + tabActions: { + flexDirection: "row", + alignItems: "center", + gap: 2, + marginLeft: space.xs, }, addTab: { + minHeight: 34, + justifyContent: "center", paddingVertical: 7, paddingHorizontal: 13, borderRadius: 14, borderWidth: 1.5, - borderColor: "rgba(155,130,180,0.5)", + borderColor: colors.dashedStrong, borderStyle: "dashed", - backgroundColor: "rgba(255,255,255,0.3)", + backgroundColor: colors.veilWeak, + }, + addTabChic: { + borderStyle: "solid", + borderWidth: 1, + borderColor: "#CCCCCC", + backgroundColor: chicTheme.card, + borderRadius: radii.tiny, }, addText: { - fontFamily: fonts.bodyBold, - fontSize: 12, - color: "#9b7fb8", + ...text.labelBold, + color: colors.lavenderDeep, + }, + addTextChic: { + color: chicTheme.inkSoft, }, }); diff --git a/src/components/board/FrameSelect.tsx b/src/components/board/FrameSelect.tsx index e00622f..f19f3bf 100644 --- a/src/components/board/FrameSelect.tsx +++ b/src/components/board/FrameSelect.tsx @@ -1,7 +1,8 @@ -import { Pressable, StyleSheet, Text, View } from "react-native"; -import { colors, fonts } from "@/lib/theme"; +import { StyleSheet, View } from "react-native"; +import { space } from "@/lib/theme"; import { FRAMES } from "@/lib/types"; import type { Frame } from "@/lib/types"; +import { Chip } from "@/components/ui/kit"; const LABELS: Record = { plain: "🕊️ シンプル", @@ -12,15 +13,10 @@ const LABELS: Record = { export function FrameSelect({ frame, onSelect }: { frame: Frame; onSelect: (f: Frame) => void }) { return ( - - {FRAMES.map((f) => { - const active = f === frame; - return ( - onSelect(f)}> - {LABELS[f]} - - ); - })} + + {FRAMES.map((f) => ( + onSelect(f)} /> + ))} ); } @@ -30,30 +26,9 @@ const styles = StyleSheet.create({ flexDirection: "row", flexWrap: "wrap", justifyContent: "center", - gap: 8, - marginTop: 14, - marginBottom: 4, - paddingHorizontal: 12, - }, - chip: { - paddingVertical: 6, - paddingHorizontal: 12, - borderRadius: 20, - borderWidth: 1.5, - borderColor: "transparent", - backgroundColor: colors.panel, - }, - chipActive: { - backgroundColor: colors.white, - borderColor: colors.lavender, - boxShadow: "0 2px 8px rgba(155,130,180,0.25)", - }, - text: { - fontFamily: fonts.body, - fontSize: 12, - color: colors.ink, - }, - textActive: { - fontFamily: fonts.bodyBold, + gap: space.sm, + marginTop: space.md, + marginBottom: space.xs, + paddingHorizontal: space.md, }, }); diff --git a/src/components/board/NoteControls.tsx b/src/components/board/NoteControls.tsx index 3b6efe3..6b90287 100644 --- a/src/components/board/NoteControls.tsx +++ b/src/components/board/NoteControls.tsx @@ -1,7 +1,8 @@ import { Pressable, StyleSheet, Text, View } from "react-native"; -import { colors, fonts } from "@/lib/theme"; -import { PAPER_COLORS, RULE_STYLES } from "@/lib/types"; +import { chic, space, text } from "@/lib/theme"; +import { colorLabel, PAPER_COLOR_LABELS, PAPER_COLORS, RULE_STYLES } from "@/lib/types"; import type { RuleStyle } from "@/lib/types"; +import { Chip } from "@/components/ui/kit"; const RULE_LABELS: Record = { lines: "横罫", @@ -23,67 +24,68 @@ export function NoteControls({ }) { return ( - - {RULE_STYLES.map((r) => { - const active = r === ruleStyle; + + {RULE_STYLES.map((r) => ( + onRule(r)} /> + ))} + + + + 用紙 + {PAPER_COLORS.map((c) => { + const selected = paperColor === c; return ( - onRule(r)}> - {RULE_LABELS[r]} + onPaper(c)} + accessibilityRole="radio" + accessibilityState={{ selected }} + accessibilityLabel={colorLabel(PAPER_COLOR_LABELS, c)} + hitSlop={6} + > + {/* 用紙色は淡い色ばかりなので、選択中かどうかを枠の色だけに + 頼らせない。チェックを重ねて形でも分かるようにする。 */} + {selected ? : null} ); })} - - {PAPER_COLORS.map((c) => ( - onPaper(c)} - /> - ))} - ); } const styles = StyleSheet.create({ wrap: { - gap: 8, - marginBottom: 10, + gap: space.sm, + marginBottom: space.sm, }, row: { flexDirection: "row", flexWrap: "wrap", + alignItems: "center", gap: 6, }, - chip: { - paddingVertical: 5, - paddingHorizontal: 10, - borderRadius: 12, - backgroundColor: colors.panel, - borderWidth: 1.5, - borderColor: "transparent", - }, - chipActive: { - borderColor: colors.lavender, - backgroundColor: colors.white, - }, - chipText: { - fontFamily: fonts.body, - fontSize: 11, - color: colors.ink, - }, - chipTextActive: { - fontFamily: fonts.bodyBold, + label: { + ...text.micro, + color: chic.inkSoft, + marginRight: 2, }, swatch: { - width: 22, - height: 22, + width: 24, + height: 24, borderRadius: 6, borderWidth: 2, - borderColor: "transparent", + borderColor: chic.border, + alignItems: "center", + justifyContent: "center", }, swatchActive: { - borderColor: colors.plum, + borderColor: chic.rule, + }, + swatchCheck: { + fontSize: 12, + lineHeight: 14, + color: chic.rule, }, }); diff --git a/src/components/board/PaperBackground.tsx b/src/components/board/PaperBackground.tsx index f70dcca..0c11c0f 100644 --- a/src/components/board/PaperBackground.tsx +++ b/src/components/board/PaperBackground.tsx @@ -1,35 +1,43 @@ +import type { ReactNode } from "react"; import { StyleSheet } from "react-native"; import Svg, { Circle, Defs, Path, Pattern, Rect } from "react-native-svg"; +import { colors } from "@/lib/theme"; import type { RuleStyle } from "@/lib/types"; +import { useSvgId } from "@/components/ui/Gradient"; -const STROKE = "rgba(155,130,180,0.35)"; +// 罫線ごとの1タイル。userSpaceOnUse の Pattern で紙全体に敷き詰める。 +const TILE: Record ReactNode }> = { + grid: { + size: 28, + draw: (stroke) => , + }, + dot: { + size: 16, + draw: (stroke) => , + }, + blank: { + size: 8, + draw: () => null, + }, + lines: { + size: 32, + draw: (stroke) => , + }, +}; export function PaperBackground({ ruleStyle, color }: { ruleStyle: RuleStyle; color: string }) { + const id = useSvgId("paper"); + const tile = TILE[ruleStyle]; + return ( - {ruleStyle === "grid" ? ( - - - - - ) : ruleStyle === "dot" ? ( - - - - - ) : ruleStyle === "blank" ? ( - - - - ) : ( - - - - - )} + + + {tile.draw(colors.line)} + - + ); } diff --git a/src/components/board/StickerItem.tsx b/src/components/board/StickerItem.tsx index 8265c8f..5ad96d8 100644 --- a/src/components/board/StickerItem.tsx +++ b/src/components/board/StickerItem.tsx @@ -1,15 +1,15 @@ -import { colors } from "@/lib/theme"; +import { chic, colors } from "@/lib/theme"; import { useNotebooks } from "@/state/notebooks"; import { STICKER_MAX_SIZE, STICKER_MIN_SIZE } from "@/lib/types"; import type { Sticker } from "@/lib/types"; -import { StickerShape } from "@/components/ui/StickerShape"; +import { StickerShape, stickerLabel } from "@/components/ui/StickerShape"; import { Transformable } from "@/components/transform/Transformable"; import type { TransformPatch } from "@/components/transform/Transformable"; export function StickerItem({ sticker, selected, - chic, + chic: chicMode, onSelect, boundsWidth, }: { @@ -31,6 +31,8 @@ export function StickerItem({ updateSticker(sticker.id, next); }; + const name = `${stickerLabel(sticker.type)}のシール`; + return ( deleteSticker(sticker.id)} diff --git a/src/components/board/StickerTray.tsx b/src/components/board/StickerTray.tsx index ab56d71..256f673 100644 --- a/src/components/board/StickerTray.tsx +++ b/src/components/board/StickerTray.tsx @@ -1,16 +1,16 @@ -import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; +import { useState } from "react"; +import { Modal, Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { colors, fonts } from "@/lib/theme"; +import { chic as chicTheme, colors, radii, space, text, TRAY_BASE_HEIGHT } from "@/lib/theme"; import { STICKER_TYPES } from "@/lib/types"; import type { StickerType } from "@/lib/types"; -import { StickerShape } from "@/components/ui/StickerShape"; +import { StickerShape, stickerLabel } from "@/components/ui/StickerShape"; interface TrayProps { isNoteStyle: boolean; sparkleOn: boolean; canUndo: boolean; connectMode: boolean; - chic: boolean; onAddSticker: (type: StickerType) => void; onAddText: () => void; onAddPhoto: () => void; @@ -28,59 +28,169 @@ function TrayButton({ onPress, active = false, disabled = false, + chicMode = false, + accessibilityLabel, }: { label: string; onPress: () => void; active?: boolean; disabled?: boolean; + chicMode?: boolean; + accessibilityLabel?: string; }) { return ( [ + styles.btn, + chicMode && styles.btnChic, + active && (chicMode ? styles.btnChicActive : styles.btnActive), + disabled && styles.btnDisabled, + pressed && styles.btnPressed, + ]} > - {label} + + {label} + + + ); +} + +function MenuRow({ + label, + hint, + onPress, + danger = false, +}: { + label: string; + hint: string; + onPress: () => void; + danger?: boolean; +}) { + return ( + [styles.menuRow, pressed && styles.btnPressed]} + > + {label} + {hint} ); } export function StickerTray(props: TrayProps) { const insets = useSafeAreaInsets(); + const [menuOpen, setMenuOpen] = useState(false); + + const runFromMenu = (action: () => void) => () => { + setMenuOpen(false); + action(); + }; return ( - - - シール - {STICKER_TYPES.map((type) => ( - props.onAddSticker(type)} style={styles.sticker} hitSlop={4}> - - - ))} + <> + + + {/* シールは11種類あるので、ひとまとまりの「シールシート」に見えるよう + 薄く敷いた帯の上に並べる */} + + シール + {STICKER_TYPES.map((type) => ( + props.onAddSticker(type)} + style={({ pressed }) => [styles.sticker, pressed && styles.btnPressed]} + hitSlop={4} + accessibilityRole="button" + accessibilityLabel={`${stickerLabel(type)}のシールを貼る`} + > + + + ))} + + + {props.isNoteStyle ? ( + <> + + + + + + ) : null} - {props.isNoteStyle ? ( - <> - - - - - - ) : null} + + + + + + setMenuOpen(true)} + chicMode={props.isNoteStyle} + accessibilityLabel="そのほかの道具" + /> + + - - - - - - - - - + setMenuOpen(false)}> + + setMenuOpen(false)} accessibilityLabel="閉じる" /> + + そのほかの道具 + + + + + + ); } @@ -90,56 +200,126 @@ const styles = StyleSheet.create({ left: 0, right: 0, bottom: 0, - backgroundColor: "rgba(255,255,255,0.92)", + minHeight: TRAY_BASE_HEIGHT, + backgroundColor: colors.veilSolid, borderTopWidth: 1.5, borderTopColor: "rgba(201,182,228,0.35)", - paddingTop: 8, + paddingTop: space.sm, zIndex: 20, }, trayChic: { backgroundColor: "rgba(255,255,255,0.96)", - borderTopColor: "#ddd", + borderTopColor: chicTheme.border, }, row: { alignItems: "center", - gap: 10, - paddingHorizontal: 10, + gap: space.sm, + paddingHorizontal: space.sm, }, - label: { - fontFamily: fonts.body, - fontSize: 10, - color: colors.ink, - opacity: 0.55, + stickerRail: { + flexDirection: "row", + alignItems: "center", + gap: 6, + paddingLeft: space.sm, + paddingRight: 6, + paddingVertical: 3, + borderRadius: radii.card, + backgroundColor: "rgba(201,182,228,0.14)", + }, + stickerRailChic: { + backgroundColor: "rgba(0,0,0,0.04)", + }, + railLabel: { + ...text.micro, + color: colors.plum, + opacity: 0.6, + }, + railLabelChic: { + color: chicTheme.inkSoft, }, sticker: { - width: 40, - height: 40, + width: 36, + height: 36, alignItems: "center", justifyContent: "center", }, divider: { width: 1, - height: 30, - backgroundColor: "rgba(155,130,180,0.25)", + height: 28, + backgroundColor: colors.hairline, }, btn: { + minHeight: 32, + justifyContent: "center", borderWidth: 1.5, - borderColor: "rgba(155,130,180,0.35)", - borderRadius: 16, + borderColor: colors.line, + borderRadius: radii.card, paddingVertical: 6, - paddingHorizontal: 12, + paddingHorizontal: space.md, backgroundColor: "transparent", }, btnActive: { borderColor: colors.gold, backgroundColor: "rgba(244,213,141,0.55)", }, + btnChic: { + borderColor: "#CCCCCC", + }, + btnChicActive: { + borderColor: chicTheme.rule, + backgroundColor: chicTheme.rule, + }, btnDisabled: { opacity: 0.35, }, + btnPressed: { + opacity: 0.7, + }, btnText: { - fontFamily: fonts.body, - fontSize: 12, + ...text.label, + color: colors.ink, + }, + btnTextChic: { + color: "#555555", + }, + btnTextChicActive: { + color: colors.white, + }, + sheetBackdrop: { + flex: 1, + justifyContent: "flex-end", + backgroundColor: "rgba(74,63,92,0.35)", + }, + sheet: { + backgroundColor: colors.paper, + borderTopLeftRadius: radii.board, + borderTopRightRadius: radii.board, + paddingHorizontal: space.lg, + paddingTop: space.lg, + gap: space.xs, + }, + sheetTitle: { + ...text.displayS, + color: colors.plum, + marginBottom: space.xs, + }, + menuRow: { + paddingVertical: space.md, + paddingHorizontal: space.md, + borderRadius: radii.small, + backgroundColor: colors.white, + }, + menuLabel: { + ...text.bodyBold, + color: colors.ink, + }, + menuLabelDanger: { + color: colors.roseDeep, + }, + menuHint: { + ...text.caption, color: colors.ink, + opacity: 0.6, + marginTop: 2, }, }); diff --git a/src/components/cover/CoverArt.tsx b/src/components/cover/CoverArt.tsx new file mode 100644 index 0000000..824a827 --- /dev/null +++ b/src/components/cover/CoverArt.tsx @@ -0,0 +1,125 @@ +import { StyleSheet, Text, View } from "react-native"; +import type { StyleProp, ViewStyle } from "react-native"; +import { darken, isLightColor, lighten } from "@/lib/color"; +import { colors, radii, shadows, space, text } from "@/lib/theme"; +import type { NotebookType } from "@/lib/types"; +import { GradientFill } from "@/components/ui/Gradient"; +import { WashiTape } from "@/components/ui/WashiTape"; + +const TAPE_WIDTH = 62; +const TAPE_HEIGHT = 16; + +export const COVER_ASPECT = 3 / 4; + +// 手帳の「表紙」そのもの。一覧のカードと、作成パネルのプレビューで共有する。 +// 背表紙・マスキングテープ・貼りラベルの3点セットが、この画面の目印。 +export function CoverArt({ + name, + type, + color, + pageCount, + bottomInset = 0, + style, +}: { + name: string; + type: NotebookType; + color: string; + pageCount: number; + // 下端にボタン帯を重ねるとき、その高さぶん中身を持ち上げる。 + bottomInset?: number; + style?: StyleProp; +}) { + const ink = isLightColor(color) ? colors.plum : colors.white; + + return ( + + + + + + + {type === "notestyle" ? "📓" : "📔"} + + + + + {name} + + + + + + {pageCount}ページ + + + ); +} + +const styles = StyleSheet.create({ + cover: { + flex: 1, + borderRadius: radii.card, + overflow: "hidden", + }, + // 表紙の左端の背表紙。これがあるだけで「色板」ではなく「本」に見える。 + spine: { + position: "absolute", + left: 0, + top: 0, + bottom: 0, + width: 11, + }, + spineHighlight: { + position: "absolute", + left: 11, + top: 0, + bottom: 0, + width: 1.5, + backgroundColor: "rgba(255,255,255,0.45)", + }, + inner: { + flex: 1, + paddingTop: space.sm, + paddingLeft: 20, + paddingRight: space.sm, + }, + badge: { + fontSize: 17, + lineHeight: 22, + }, + labelHolder: { + flex: 1, + justifyContent: "center", + paddingTop: space.sm, + }, + // 表紙に貼った名前ラベル。マステで留まっているように見せる。 + label: { + backgroundColor: colors.paper, + borderRadius: radii.tiny, + paddingVertical: 7, + paddingHorizontal: space.sm, + boxShadow: shadows.card, + }, + labelText: { + ...text.handwritten, + color: colors.plum, + textAlign: "center", + }, + tape: { + position: "absolute", + top: 0, + left: "50%", + marginLeft: -TAPE_WIDTH / 2, + }, + meta: { + ...text.micro, + opacity: 0.85, + }, +}); diff --git a/src/components/cover/CoverGrid.tsx b/src/components/cover/CoverGrid.tsx index e1de8bb..ef62066 100644 --- a/src/components/cover/CoverGrid.tsx +++ b/src/components/cover/CoverGrid.tsx @@ -1,50 +1,109 @@ import { Pressable, StyleSheet, Text, View } from "react-native"; -import { colors, fonts, radii } from "@/lib/theme"; +import Animated, { FadeInDown } from "react-native-reanimated"; import { notebookDisplayName } from "@/lib/model"; +import { colors, radii, shadows, space, text } from "@/lib/theme"; import type { Notebook } from "@/lib/types"; +import { IconButton } from "@/components/ui/kit"; +import { COVER_ASPECT, CoverArt } from "./CoverArt"; + +const ACTION_BAR_HEIGHT = 36; + +// 表紙が順に浮かび上がる演出。冊数が増えても待たされないよう、ずらす段数に +// 上限を設ける(100冊あっても最後まで 0.36 秒で出そろう)。 +const STAGGER_MS = 45; +const STAGGER_MAX_STEPS = 8; + +function enterDelay(index: number): number { + return Math.min(index, STAGGER_MAX_STEPS) * STAGGER_MS; +} + +function CoverCard({ + notebook, + index, + onOpen, + onRename, + onDuplicate, + onDelete, +}: { + notebook: Notebook; + index: number; + onOpen: () => void; + onRename: () => void; + onDuplicate: () => void; + onDelete: () => void; +}) { + const name = notebookDisplayName(notebook); + + return ( + + {/* カードの外枠は押せない容れ物にして、「開く」と操作ボタンを兄弟に並べる。 + 押せる要素を入れ子にすると TalkBack が外側だけをひとかたまりで拾い、 + 名前の変更・コピー・削除に降りられなくなるため(BoardTabs と同じ扱い)。 */} + + [styles.opener, pressed && styles.cardPressed]} + onPress={onOpen} + accessibilityRole="button" + accessibilityLabel={`${name}を開く`} + > + + + + + + + + + + ); +} export function CoverGrid({ notebooks, onOpen, onRename, + onDuplicate, onDelete, onAdd, }: { notebooks: Notebook[]; onOpen: (id: string) => void; onRename: (id: string) => void; + onDuplicate: (id: string) => void; onDelete: (id: string) => void; onAdd: () => void; }) { return ( - {notebooks.map((nb) => ( - - - {} - onOpen(nb.id)} /> - - {nb.type === "notestyle" ? "📓" : "📔"} - - onRename(nb.id)} hitSlop={6}> - ✏️ - - onDelete(nb.id)} hitSlop={6}> - - - - {notebookDisplayName(nb)} - - - + {notebooks.map((nb, index) => ( + onOpen(nb.id)} + onRename={() => onRename(nb.id)} + onDuplicate={() => onDuplicate(nb.id)} + onDelete={() => onDelete(nb.id)} + /> ))} - - + + [styles.addCard, pressed && styles.cardPressed]} + onPress={onAdd} + accessibilityRole="button" + accessibilityLabel="新しい手帳をつくる" + > 新しい手帳 - - + + ); } @@ -53,77 +112,57 @@ const styles = StyleSheet.create({ grid: { flexDirection: "row", flexWrap: "wrap", - gap: 16, + gap: space.lg, }, cell: { width: "47%", flexGrow: 1, - aspectRatio: 3 / 4, + aspectRatio: COVER_ASPECT, }, card: { flex: 1, borderRadius: radii.card, - padding: 14, - justifyContent: "flex-end", - boxShadow: "0 4px 14px rgba(90,70,110,0.18)", + overflow: "hidden", + boxShadow: shadows.raised, }, - badge: { - position: "absolute", - top: 10, - left: 10, - width: 30, - height: 30, - borderRadius: 15, - backgroundColor: "rgba(255,255,255,0.35)", - alignItems: "center", - justifyContent: "center", + // 表紙ぜんぶが「開く」の当たり判定。ボタン帯は上に重なるのでそちらが優先される。 + opener: { + flex: 1, }, - badgeText: { - fontSize: 16, + cardPressed: { + opacity: 0.9, }, - corner: { + actions: { position: "absolute", - top: 8, - width: 24, - height: 24, - borderRadius: 12, - backgroundColor: "rgba(255,255,255,0.55)", + left: 0, + right: 0, + bottom: 0, + height: ACTION_BAR_HEIGHT, + flexDirection: "row", + justifyContent: "flex-end", alignItems: "center", - justifyContent: "center", - }, - edit: { - right: 36, - }, - del: { - right: 8, - }, - cornerText: { - fontSize: 11, - color: colors.plum, - }, - name: { - fontFamily: fonts.display, - fontSize: 16, - color: colors.white, + gap: space.xs, + paddingHorizontal: space.sm, + backgroundColor: "rgba(255,255,255,0.22)", }, addCard: { flex: 1, borderRadius: radii.card, borderWidth: 2, - borderColor: "rgba(155,130,180,0.5)", + borderColor: colors.dashedStrong, borderStyle: "dashed", - backgroundColor: "rgba(255,255,255,0.3)", + backgroundColor: colors.veilWeak, alignItems: "center", justifyContent: "center", - gap: 6, + gap: space.xs, }, addPlus: { - fontSize: 24, - color: "#9b7fb8", + fontSize: 26, + lineHeight: 32, + color: colors.lavenderDeep, }, addLabel: { - fontFamily: fonts.body, - fontSize: 12, - color: "#9b7fb8", + ...text.label, + color: colors.lavenderDeep, }, }); diff --git a/src/components/cover/CreatePanel.tsx b/src/components/cover/CreatePanel.tsx index 576b2b3..8779b7a 100644 --- a/src/components/cover/CreatePanel.tsx +++ b/src/components/cover/CreatePanel.tsx @@ -1,13 +1,14 @@ import { useState } from "react"; import { Pressable, StyleSheet, Text, View } from "react-native"; -import { colors, fonts } from "@/lib/theme"; -import { PALETTE_COLORS } from "@/lib/types"; +import { colors, radii, shadows, space, text } from "@/lib/theme"; +import { colorLabel, COLOR_LABELS, DEFAULT_NOTEBOOK_COLOR, MAX_NOTEBOOK_NAME, PALETTE_COLORS } from "@/lib/types"; import type { NotebookType } from "@/lib/types"; -import { AppButton, AppTextInput, Card } from "@/components/ui/kit"; +import { AppButton, AppTextInput, Card, PanelTitle } from "@/components/ui/kit"; +import { CoverArt } from "./CoverArt"; const TYPE_OPTIONS: { key: NotebookType; label: string; sub: string }[] = [ - { key: "notestyle", label: "📓 ノート式", sub: "白紙から自由にデコる" }, - { key: "profile", label: "📔 プロフィール帳式", sub: "最初からかわいいテンプレ" }, + { key: "notestyle", label: "📓 ノート式", sub: "白紙に自由に置いてデコる" }, + { key: "profile", label: "📔 プロフィール帳式", sub: "飾り枠つきの工程ページ" }, ]; export function CreatePanel({ @@ -18,47 +19,66 @@ export function CreatePanel({ onCancel: () => void; }) { const [type, setType] = useState("profile"); - const [color, setColor] = useState(PALETTE_COLORS[0]); + const [color, setColor] = useState(DEFAULT_NOTEBOOK_COLOR); const [name, setName] = useState(""); + const previewName = name.trim() || (type === "notestyle" ? "無題のノート" : "無題のプロフィール帳"); + return ( - 新しい手帳をつくる + 新しい手帳をつくる - - {TYPE_OPTIONS.map((opt) => { - const selected = type === opt.key; - return ( - setType(opt.key)} - > - {opt.label} - {opt.sub} - - ); - })} - + {/* 選んだ内容がそのまま表紙になる。作る前に仕上がりが見えるようにしている */} + + + + - - {PALETTE_COLORS.map((c) => ( - setColor(c)} - /> - ))} + + {TYPE_OPTIONS.map((opt) => { + const selected = type === opt.key; + return ( + [styles.typeBtn, selected && styles.typeBtnSelected, pressed && styles.pressed]} + onPress={() => setType(opt.key)} + accessibilityRole="radio" + accessibilityState={{ selected }} + > + {opt.label} + {opt.sub} + + ); + })} + + 表紙の色 + + {PALETTE_COLORS.map((c) => { + const selected = color === c; + return ( + setColor(c)} + accessibilityRole="radio" + accessibilityState={{ selected }} + accessibilityLabel={colorLabel(COLOR_LABELS, c)} + hitSlop={6} + /> + ); + })} + + { + const seed = useMemo(() => { const angle = Math.random() * Math.PI * 2; const dist = 40 + Math.random() * 40; - return { dx: Math.cos(angle) * dist, dy: Math.sin(angle) * dist - 20 }; + return { + dx: Math.cos(angle) * dist, + dy: Math.sin(angle) * dist - 20, + spin: degrees(Math.random() * Math.PI * 2), + type: randomOf(STICKER_TYPES) ?? "star", + }; }, []); useEffect(() => { - p.value = withTiming(1, { duration: 700, easing: Easing.out(Easing.quad) }); + p.value = withTiming(1, { duration: DURATION, easing: Easing.out(Easing.quad) }); }, [p]); const style = useAnimatedStyle(() => ({ opacity: 1 - p.value, - transform: [{ translateX: target.dx * p.value }, { translateY: target.dy * p.value }], + transform: [ + { translateX: seed.dx * p.value }, + { translateY: seed.dy * p.value }, + { rotate: `${seed.spin * p.value}deg` }, + ], })); - const type = STICKER_TYPES[index % STICKER_TYPES.length] ?? "star"; return ( - + ); } +// 工程にチェックが付いたときにシールが弾ける演出。 +// 「視差効果を減らす」設定が入っている端末では出さない。 export function Confetti() { + const reduced = useReducedMotion(); + if (reduced) return null; + return ( - {Array.from({ length: 6 }, (_, i) => ( - + {Array.from({ length: SPRITES }, (_, i) => ( + ))} ); diff --git a/src/components/flowchart/FlowchartSection.tsx b/src/components/flowchart/FlowchartSection.tsx index 5ecf51c..76dab97 100644 --- a/src/components/flowchart/FlowchartSection.tsx +++ b/src/components/flowchart/FlowchartSection.tsx @@ -1,8 +1,11 @@ import { Fragment } from "react"; -import { Pressable, StyleSheet, Text, View } from "react-native"; -import { colors, fonts } from "@/lib/theme"; -import { useNotebooks } from "@/state/notebooks"; +import { StyleSheet, Text, View } from "react-native"; +import { stepProgress } from "@/lib/model"; +import { colors, space, text } from "@/lib/theme"; import type { Page } from "@/lib/types"; +import { useNotebooks } from "@/state/notebooks"; +import { useUi } from "@/state/ui"; +import { AppButton } from "@/components/ui/kit"; import { StepCard } from "./StepCard"; import { IfCard } from "./IfCard"; @@ -15,35 +18,63 @@ function Connector() { ); } +// 「いくつ終わったか」は工程表そのものの情報なので、飾りではなく見出しとして置く。 +function Progress({ done, total }: { done: number; total: number }) { + const ratio = total === 0 ? 0 : done / total; + const complete = total > 0 && done === total; + return ( + + + + + {complete ? `ぜんぶできた!🎉 ${done}/${total}` : `${done}/${total}`} + + ); +} + export function FlowchartSection({ page }: { page: Page }) { const addStep = useNotebooks((s) => s.addStep); const addIfStep = useNotebooks((s) => s.addIfStep); + const setFocus = useUi((s) => s.setFocus); + + const { done, total } = stepProgress(page.steps); return ( {page.steps.length === 0 ? ( - まだ工程がありません…{"\n"}下の「+ 工程を追加」から始めてみてね🐰 + ここは工程表のページ。{"\n"}下の「+ 工程を追加」から1つめを書いてみてね🐰 ) : ( - - {page.steps.map((step, index) => ( - - {index > 0 ? : null} - {step.type === "if" ? ( - - ) : ( - - )} - - ))} - + <> + {total > 0 ? : null} + + {page.steps.map((step, index) => ( + + {index > 0 ? : null} + {step.type === "if" ? ( + + ) : ( + + )} + + ))} + + )} - - + 工程を追加 - - - +🔀 if分岐を追加 - + + setFocus(addStep())} style={styles.addMain} /> + setFocus(addIfStep())} + style={styles.addIf} + /> + ); } @@ -52,11 +83,34 @@ const styles = StyleSheet.create({ empty: { textAlign: "center", opacity: 0.55, - fontFamily: fonts.display, - fontSize: 16, + ...text.displayS, color: colors.ink, - paddingVertical: 20, - lineHeight: 26, + paddingVertical: space.xl, + }, + progress: { + flexDirection: "row", + alignItems: "center", + gap: space.sm, + alignSelf: "center", + width: "100%", + maxWidth: 420, + marginBottom: space.md, + }, + progressTrack: { + flex: 1, + height: 6, + borderRadius: 3, + backgroundColor: colors.veilWeak, + overflow: "hidden", + }, + progressFill: { + height: "100%", + borderRadius: 3, + backgroundColor: colors.mint, + }, + progressText: { + ...text.caption, + color: colors.plum, }, steps: { alignItems: "center", @@ -83,33 +137,16 @@ const styles = StyleSheet.create({ borderRightColor: "transparent", borderTopColor: colors.connector, }, + actions: { + alignItems: "center", + gap: space.sm, + marginTop: space.xl, + }, addMain: { - alignSelf: "center", - marginTop: 22, - backgroundColor: colors.lavender, - paddingVertical: 11, paddingHorizontal: 26, - borderRadius: 24, - boxShadow: "0 4px 12px rgba(155,130,180,0.35)", - }, - addMainText: { - fontFamily: fonts.bodyBold, - fontSize: 14, - color: colors.white, }, addIf: { - alignSelf: "center", - marginTop: 10, - borderWidth: 1.5, - borderColor: "rgba(155,130,180,0.5)", - borderStyle: "dashed", - paddingVertical: 9, paddingHorizontal: 22, - borderRadius: 24, - }, - addIfText: { - fontFamily: fonts.bodyBold, - fontSize: 13, - color: colors.plum, + paddingVertical: 9, }, }); diff --git a/src/components/flowchart/IfCard.tsx b/src/components/flowchart/IfCard.tsx index 0534aa1..fed821c 100644 --- a/src/components/flowchart/IfCard.tsx +++ b/src/components/flowchart/IfCard.tsx @@ -1,28 +1,30 @@ import { Pressable, StyleSheet, Text, TextInput, View } from "react-native"; -import { colors, fonts, radii } from "@/lib/theme"; +import { successFeedback } from "@/lib/haptics"; +import { colors, radii, shadows, space, text } from "@/lib/theme"; import { useNotebooks } from "@/state/notebooks"; -import type { BranchKey, IfStep, NormalStep } from "@/lib/types"; +import { useUi } from "@/state/ui"; +import { BRANCH_KEYS, MAX_BRANCH_LABEL } from "@/lib/types"; +import type { BranchKey, IfStep, NormalStep, Step } from "@/lib/types"; +import { StepActions } from "./StepActions"; -// 最上位(cb.steps 直下)の if 分岐カード。上下移動・削除ボタン付き。 +interface Position { + isFirst: boolean; + isLast: boolean; +} + +// 最上位(page.steps 直下)の if 分岐カード。 export function IfCard({ step, index, count }: { step: IfStep; index: number; count: number }) { - const deleteStep = useNotebooks((s) => s.deleteStep); - return deleteStep(step.id)} move={{ index, count }} />; + return ; } // if 分岐カードの共通描画(トップレベルでも分岐の中=ネストでも使う再帰対応版)。 -function IfCardNode({ - step, - depth, - onDelete, - move, -}: { - step: IfStep; - depth: number; - onDelete: () => void; - move?: { index: number; count: number }; -}) { +// 「はい/いいえ」は画面幅やネストの深さによらず常に縦1カラムに積む。横に並べると +// ネストが深いときに文字が潰れるため、Web版もこの見た目に統一されている。 +function IfCardNode({ step, depth, position }: { step: IfStep; depth: number; position: Position }) { const setStepText = useNotebooks((s) => s.setStepText); - const moveStep = useNotebooks((s) => s.moveStep); + const deleteStep = useNotebooks((s) => s.deleteStep); + const focusId = useUi((s) => s.focusId); + const setFocus = useUi((s) => s.setFocus); return ( setStepText(step.id, t)} multiline placeholder="条件を入力…(例:時間があるか?)" - placeholderTextColor="rgba(90,77,112,0.4)" + placeholderTextColor={colors.placeholder} + autoFocus={focusId === step.id} + onFocus={() => focusId === step.id && setFocus(null)} + accessibilityLabel="分かれ道の条件" style={styles.cond} /> - - {move ? ( - <> - moveStep(move.index, -1)} disabled={move.index === 0} hitSlop={4}> - - - moveStep(move.index, 1)} disabled={move.index === move.count - 1} hitSlop={4}> - - - - ) : null} - - - - + deleteStep(step.id)} + /> - - + {BRANCH_KEYS.map((key) => ( + + ))} ); @@ -72,28 +70,47 @@ function BranchColumn({ step, branchKey, depth }: { step: IfStep; branchKey: Bra const setBranchLabel = useNotebooks((s) => s.setBranchLabel); const addBranchStep = useNotebooks((s) => s.addBranchStep); const addBranchIfStep = useNotebooks((s) => s.addBranchIfStep); + const setFocus = useUi((s) => s.setFocus); + + const items = step.branches[branchKey]; return ( setBranchLabel(step.id, branchKey, t)} - maxLength={12} + maxLength={MAX_BRANCH_LABEL} + accessibilityLabel={branchKey === "yes" ? "はい側のラベル" : "いいえ側のラベル"} style={styles.branchLabel} /> + {/* 分岐の中身は「工程カード」か「入れ子 if 分岐カード」のどちらか */} - {step.branches[branchKey].map((item) => - item.type === "if" ? ( - + {items.map((item: Step, index: number) => { + const position = { isFirst: index === 0, isLast: index === items.length - 1 }; + return item.type === "if" ? ( + + + ) : ( - - ), - )} + + ); + })} + - addBranchStep(step.id, branchKey)}> + setFocus(addBranchStep(step.id, branchKey))} + accessibilityRole="button" + accessibilityLabel="この分岐に工程を追加" + > +工程 - addBranchIfStep(step.id, branchKey)}> + setFocus(addBranchIfStep(step.id, branchKey))} + accessibilityRole="button" + accessibilityLabel="この分岐にif分岐を追加" + > +🔀if @@ -101,40 +118,50 @@ function BranchColumn({ step, branchKey, depth }: { step: IfStep; branchKey: Bra ); } -function NestedIfItem({ step, depth }: { step: IfStep; depth: number }) { - const deleteStep = useNotebooks((s) => s.deleteStep); - return ( - - deleteStep(step.id)} /> - - ); -} - -function BranchStepRow({ branch }: { branch: NormalStep }) { +function BranchStepRow({ branch, position }: { branch: NormalStep; position: Position }) { const setStepText = useNotebooks((s) => s.setStepText); const toggleStep = useNotebooks((s) => s.toggleStep); const deleteStep = useNotebooks((s) => s.deleteStep); + const focusId = useUi((s) => s.focusId); + const setFocus = useUi((s) => s.setFocus); + + // 手ごたえは工程カードと揃える(紙吹雪は最上位の工程だけの演出のまま)。 + const onCheck = () => { + if (toggleStep(branch.id)) successFeedback(); + }; return ( toggleStep(branch.id)} - hitSlop={4} + onPress={onCheck} + hitSlop={8} + accessibilityRole="checkbox" + accessibilityState={{ checked: branch.done }} + accessibilityLabel={branch.text.trim() || "空の工程"} > {branch.done ? "✓" : ""} + setStepText(branch.id, t)} multiline placeholder="工程を入力…" - placeholderTextColor="rgba(90,77,112,0.4)" + placeholderTextColor={colors.placeholder} + autoFocus={focusId === branch.id} + onFocus={() => focusId === branch.id && setFocus(null)} + accessibilityLabel="工程の内容" style={[styles.branchText, branch.done && styles.branchTextDone]} /> - deleteStep(branch.id)} hitSlop={4}> - - + + deleteStep(branch.id)} + /> ); } @@ -142,74 +169,58 @@ function BranchStepRow({ branch }: { branch: NormalStep }) { const styles = StyleSheet.create({ card: { width: "100%", - backgroundColor: "#fff8ef", + backgroundColor: colors.ifCard.bg, borderWidth: 1.5, - borderColor: "#d9a441", + borderColor: colors.ifCard.border, borderStyle: "dashed", borderRadius: radii.card, - padding: 12, - boxShadow: "0 3px 10px rgba(90,70,110,0.1)", + padding: space.md, + boxShadow: shadows.card, }, cardTop: { maxWidth: 420, alignSelf: "center", }, cardNested: { - padding: 10, + padding: space.sm + 2, }, cardNest1: { - backgroundColor: "#faf5ff", - borderColor: "#c9a0d9", + backgroundColor: colors.ifCardNest1.bg, + borderColor: colors.ifCardNest1.border, }, cardNest2: { - backgroundColor: "#f2f8ff", - borderColor: "#a3c9e0", + backgroundColor: colors.ifCardNest2.bg, + borderColor: colors.ifCardNest2.border, }, head: { flexDirection: "row", alignItems: "flex-start", gap: 6, - marginBottom: 10, + marginBottom: space.sm, }, ifIcon: { - fontFamily: fonts.body, - fontSize: 13, + ...text.body, marginTop: 3, }, cond: { flex: 1, - fontFamily: fonts.body, + ...text.body, fontSize: 14, color: colors.ink, - lineHeight: 20, padding: 0, }, - actions: { - alignItems: "center", - gap: 2, - }, - icon: { - fontSize: 12, - color: "#b5a8c4", - paddingVertical: 2, - }, - iconHidden: { - opacity: 0, - }, - // スマホ幅では「はい/いいえ」を縦積みにして、ネストが深くても横幅で潰れないようにする。 branches: { - gap: 10, + gap: space.sm, }, branch: { width: "100%", backgroundColor: "rgba(255,255,255,0.6)", - borderRadius: 12, - padding: 8, + borderRadius: radii.small, + padding: space.sm, gap: 6, }, branchLabel: { - fontFamily: fonts.bodyBold, - fontSize: 12, + ...text.labelBold, color: colors.plum, textAlign: "center", padding: 2, @@ -222,9 +233,9 @@ const styles = StyleSheet.create({ alignItems: "flex-start", gap: 5, backgroundColor: colors.paper, - borderRadius: 10, + borderRadius: radii.small, paddingVertical: 7, - paddingHorizontal: 8, + paddingHorizontal: space.sm, boxShadow: "0 2px 6px rgba(90,70,110,0.08)", }, bcheck: { @@ -250,21 +261,16 @@ const styles = StyleSheet.create({ }, branchText: { flex: 1, - fontFamily: fonts.body, + ...text.caption, fontSize: 12.5, - color: colors.ink, lineHeight: 18, + color: colors.ink, padding: 0, }, branchTextDone: { opacity: 0.55, textDecorationLine: "line-through", }, - branchDel: { - fontSize: 10, - color: "#b5a8c4", - paddingTop: 2, - }, branchAddRow: { flexDirection: "row", gap: 6, @@ -273,15 +279,14 @@ const styles = StyleSheet.create({ }, branchAdd: { borderWidth: 1, - borderColor: "rgba(155,130,180,0.4)", + borderColor: colors.dashed, borderStyle: "dashed", - borderRadius: 10, - paddingVertical: 4, - paddingHorizontal: 10, + borderRadius: radii.small, + paddingVertical: 5, + paddingHorizontal: space.sm + 2, }, branchAddText: { - fontFamily: fonts.body, - fontSize: 11, - color: "#9b7fb8", + ...text.caption, + color: colors.lavenderDeep, }, }); diff --git a/src/components/flowchart/StepActions.tsx b/src/components/flowchart/StepActions.tsx new file mode 100644 index 0000000..998b622 --- /dev/null +++ b/src/components/flowchart/StepActions.tsx @@ -0,0 +1,77 @@ +import { Pressable, StyleSheet, Text, View } from "react-native"; +import { colors, space } from "@/lib/theme"; +import { useNotebooks } from "@/state/notebooks"; + +// 工程カード/if分岐カードの右肩に並ぶ「▲ ▼ ✕」。並びの中で1つしかないときは +// 矢印を出さない(押せないボタンを置かない)。 +export function StepActions({ + stepId, + isFirst, + isLast, + onDelete, + deleteLabel = "この工程を削除", + compact = false, +}: { + stepId: string; + isFirst: boolean; + isLast: boolean; + onDelete: () => void; + deleteLabel?: string; + compact?: boolean; +}) { + const moveStep = useNotebooks((s) => s.moveStep); + const movable = !(isFirst && isLast); + + return ( + + {movable ? ( + <> + moveStep(stepId, -1)} + disabled={isFirst} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel="ひとつ上へ" + accessibilityState={{ disabled: isFirst }} + > + + + moveStep(stepId, 1)} + disabled={isLast} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel="ひとつ下へ" + accessibilityState={{ disabled: isLast }} + > + + + + ) : null} + + + + + ); +} + +const styles = StyleSheet.create({ + actions: { + alignItems: "center", + gap: space.xs / 2, + }, + icon: { + fontSize: 12, + lineHeight: 14, + color: colors.inkSoft, + paddingVertical: 2, + paddingHorizontal: 2, + }, + iconCompact: { + fontSize: 10, + lineHeight: 12, + }, + iconHidden: { + opacity: 0, + }, +}); diff --git a/src/components/flowchart/StepCard.tsx b/src/components/flowchart/StepCard.tsx index 9218712..0c06d10 100644 --- a/src/components/flowchart/StepCard.tsx +++ b/src/components/flowchart/StepCard.tsx @@ -1,25 +1,41 @@ import { useState } from "react"; import { Pressable, StyleSheet, Text, TextInput, View } from "react-native"; -import { colors, fonts, radii } from "@/lib/theme"; +import { successFeedback } from "@/lib/haptics"; +import { colors, radii, shadows, space, text } from "@/lib/theme"; import { useNotebooks } from "@/state/notebooks"; +import { useUi } from "@/state/ui"; import type { NormalStep } from "@/lib/types"; import { Confetti } from "./Confetti"; +import { StepActions } from "./StepActions"; export function StepCard({ step, index, count }: { step: NormalStep; index: number; count: number }) { const setStepText = useNotebooks((s) => s.setStepText); const toggleStep = useNotebooks((s) => s.toggleStep); - const moveStep = useNotebooks((s) => s.moveStep); const deleteStep = useNotebooks((s) => s.deleteStep); + const focusId = useUi((s) => s.focusId); + const setFocus = useUi((s) => s.setFocus); const [burst, setBurst] = useState(0); const onCheck = () => { const nowDone = toggleStep(step.id); - if (nowDone) setBurst((b) => b + 1); + if (nowDone) { + setBurst((b) => b + 1); + successFeedback(); + } }; + const label = step.text.trim() || "空の工程"; + return ( - + {step.done ? "✓" : ""} @@ -29,23 +45,21 @@ export function StepCard({ step, index, count }: { step: NormalStep; index: numb onChangeText={(t) => setStepText(step.id, t)} multiline placeholder="工程を入力…" - placeholderTextColor="rgba(90,77,112,0.4)" + placeholderTextColor={colors.placeholder} + autoFocus={focusId === step.id} + onFocus={() => focusId === step.id && setFocus(null)} + accessibilityLabel="工程の内容" style={[styles.text, step.done && styles.textDone]} /> {step.done ? : null} - - moveStep(index, -1)} disabled={index === 0} hitSlop={4}> - - - moveStep(index, 1)} disabled={index === count - 1} hitSlop={4}> - - - deleteStep(step.id)} hitSlop={4}> - - - + deleteStep(step.id)} + /> {burst > 0 ? : null} @@ -59,15 +73,15 @@ const styles = StyleSheet.create({ alignSelf: "center", backgroundColor: colors.paper, borderRadius: radii.card, - paddingVertical: 12, - paddingHorizontal: 12, + paddingVertical: space.md, + paddingHorizontal: space.md, flexDirection: "row", alignItems: "flex-start", - gap: 8, - boxShadow: "0 3px 10px rgba(90,70,110,0.1)", + gap: space.sm, + boxShadow: shadows.card, }, cardDone: { - backgroundColor: "#f4f1e8", + backgroundColor: colors.stepDone, }, check: { width: 26, @@ -96,10 +110,8 @@ const styles = StyleSheet.create({ justifyContent: "center", }, text: { - fontFamily: fonts.body, - fontSize: 15, + ...text.bodyL, color: colors.ink, - lineHeight: 22, padding: 0, margin: 0, }, @@ -115,16 +127,4 @@ const styles = StyleSheet.create({ backgroundColor: colors.rose, borderRadius: 2, }, - actions: { - alignItems: "center", - gap: 2, - }, - icon: { - fontSize: 12, - color: "#b5a8c4", - paddingVertical: 2, - }, - iconHidden: { - opacity: 0, - }, }); diff --git a/src/components/freeform/ArrowLayer.tsx b/src/components/freeform/ArrowLayer.tsx index 0e855a9..ef0ad91 100644 --- a/src/components/freeform/ArrowLayer.tsx +++ b/src/components/freeform/ArrowLayer.tsx @@ -1,9 +1,12 @@ -import { Pressable, StyleSheet, Text, View } from "react-native"; -import { GRIP_RESERVE } from "@/components/transform/Transformable"; -import { colors } from "@/lib/theme"; +import { StyleSheet, View } from "react-native"; +import { GRIP_RESERVE, Transformable } from "@/components/transform/Transformable"; +import type { TransformPatch } from "@/components/transform/Transformable"; +import { clamp, degrees } from "@/lib/format"; +import { chic } from "@/lib/theme"; +import { ARROW_MAX_LENGTH, ARROW_MIN_LENGTH, UNMEASURED_SHAPE_HEIGHT } from "@/lib/types"; +import type { Arrow, Page, Shape } from "@/lib/types"; import { useNotebooks } from "@/state/notebooks"; import { useUi } from "@/state/ui"; -import type { Arrow, Page, Shape } from "@/lib/types"; interface Geo { mx: number; @@ -12,14 +15,19 @@ interface Geo { angle: number; } -const ARROW_COLOR = "#888"; +export const ARROW_HEIGHT = 22; + +// 手で動かした矢印の中心を紙の中に留めておくための余白。線そのものは +// 回転して紙からはみ出してもよいが、中心(=掴める場所)が紙の外に出ると +// 選び直せず、消すことも戻すこともできなくなる。 +const CENTER_MARGIN = 24; function centerOf(shape: Shape, height: number, boundsWidth: number | undefined): { cx: number; cy: number } { const w = boundsWidth !== undefined ? Math.min(shape.w, boundsWidth) : shape.w; const maxX = boundsWidth !== undefined ? Math.max(0, boundsWidth - w) : Number.POSITIVE_INFINITY; const x = Math.min(maxX, Math.max(0, shape.x)); const y = Math.max(GRIP_RESERVE, shape.y); - return { cx: x + w / 2, cy: y + (height || 44) / 2 }; + return { cx: x + w / 2, cy: y + (height || UNMEASURED_SHAPE_HEIGHT) / 2 }; } function geometryFor( @@ -34,48 +42,85 @@ function geometryFor( const from = shapes.find((s) => s.id === arrow.from); const to = shapes.find((s) => s.id === arrow.to); if (!from || !to) return null; - const a = centerOf(from, heights[from.id] ?? 44, boundsWidth); - const b = centerOf(to, heights[to.id] ?? 44, boundsWidth); + const a = centerOf(from, heights[from.id] ?? UNMEASURED_SHAPE_HEIGHT, boundsWidth); + const b = centerOf(to, heights[to.id] ?? UNMEASURED_SHAPE_HEIGHT, boundsWidth); const dx = b.cx - a.cx; const dy = b.cy - a.cy; return { mx: (a.cx + b.cx) / 2, my: (a.cy + b.cy) / 2, - length: Math.max(20, Math.hypot(dx, dy)), - angle: (Math.atan2(dy, dx) * 180) / Math.PI, + length: Math.max(ARROW_MIN_LENGTH, Math.hypot(dx, dy)), + angle: degrees(Math.atan2(dy, dx)), }; } -function ArrowItem({ arrow, geo, selected }: { arrow: Arrow; geo: Geo; selected: boolean }) { +function ArrowItem({ + arrow, + geo, + selected, + boundsWidth, +}: { + arrow: Arrow; + geo: Geo; + selected: boolean; + boundsWidth: number | undefined; +}) { const select = useUi((s) => s.select); + const updateArrow = useNotebooks((s) => s.updateArrow); const deleteArrow = useNotebooks((s) => s.deleteArrow); const resetArrow = useNotebooks((s) => s.resetArrow); - const len = Math.max(20, geo.length); + + const len = Math.max(ARROW_MIN_LENGTH, geo.length); + const left = geo.mx - len / 2; + const top = geo.my - ARROW_HEIGHT / 2; + + // 中央+長さ+角度で持っている矢印を、左上+幅+回転で扱う Transformable に橋渡しする。 + // ドラッグ・拡大・回転のどれかを触った時点で「手動配置」に切り替わる。 + // 丸めるのは箱の左上ではなく中心。回転した矢印では箱の左上が紙の外に出るのは + // 正常(縦向きの線など)なので、掴める中心のほうを紙の中に留める。 + const onChange = (patch: TransformPatch) => { + const nextLen = patch.w ?? len; + const cx = (patch.x ?? left) + nextLen / 2; + const cy = (patch.y ?? top) + ARROW_HEIGHT / 2; + updateArrow(arrow.id, { + manual: true, + mx: Math.round( + boundsWidth === undefined ? cx : clamp(cx, CENTER_MARGIN, Math.max(CENTER_MARGIN, boundsWidth - CENTER_MARGIN)), + ), + my: Math.round(Math.max(CENTER_MARGIN, cy)), + length: Math.round(nextLen), + angle: Math.round(patch.rot ?? geo.angle), + }); + }; return ( - select(arrow.id)} - style={[ - styles.wrap, - selected ? styles.wrapSelected : null, - { left: geo.mx - len / 2, top: geo.my - 11, width: len, transform: [{ rotate: `${geo.angle}deg` }] }, - ]} + resetArrow(arrow.id) } } + : {})} + onSelect={() => select(arrow.id)} + onChange={onChange} + onDelete={() => deleteArrow(arrow.id)} > - - - {selected ? ( - <> - deleteArrow(arrow.id)} hitSlop={6}> - - - {arrow.manual ? ( - resetArrow(arrow.id)} hitSlop={6}> - - - ) : null} - - ) : null} - + + + + + ); } @@ -95,21 +140,25 @@ export function ArrowLayer({ {page.arrows.map((arrow) => { const geo = geometryFor(arrow, page.shapes, heights, boundsWidth); if (!geo) return null; - return ; + return ( + + ); })} ); } const styles = StyleSheet.create({ - wrap: { - position: "absolute", - height: 22, + body: { + height: ARROW_HEIGHT, justifyContent: "center", }, - wrapSelected: { - zIndex: 3, - }, line: { position: "absolute", left: 0, @@ -118,6 +167,7 @@ const styles = StyleSheet.create({ height: 2.5, marginTop: -1.25, borderRadius: 2, + backgroundColor: chic.arrow, }, lineSelected: { boxShadow: "0 0 0 3px rgba(155,130,180,0.25)", @@ -134,34 +184,6 @@ const styles = StyleSheet.create({ borderLeftWidth: 9, borderTopColor: "transparent", borderBottomColor: "transparent", - }, - btn: { - position: "absolute", - top: -9, - width: 18, - height: 18, - borderRadius: 9, - backgroundColor: colors.white, - borderWidth: 1.5, - alignItems: "center", - justifyContent: "center", - zIndex: 4, - }, - del: { - right: -9, - borderColor: colors.rose, - }, - delText: { - fontSize: 10, - fontWeight: "700", - color: colors.rose, - }, - reset: { - left: -9, - borderColor: colors.lavender, - }, - resetText: { - fontSize: 12, - color: colors.plum, + borderLeftColor: chic.arrow, }, }); diff --git a/src/components/freeform/FreeformCanvas.tsx b/src/components/freeform/FreeformCanvas.tsx index 6dc9a9a..d538b0e 100644 --- a/src/components/freeform/FreeformCanvas.tsx +++ b/src/components/freeform/FreeformCanvas.tsx @@ -1,14 +1,18 @@ import { useMemo, useState } from "react"; import { Pressable, StyleSheet, Text, View } from "react-native"; -import { colors, fonts } from "@/lib/theme"; +import { chic, colors, radii, space, text } from "@/lib/theme"; import { useNotebooks } from "@/state/notebooks"; import { useUi } from "@/state/ui"; +import { UNMEASURED_SHAPE_HEIGHT } from "@/lib/types"; import type { Page } from "@/lib/types"; import { StickerItem } from "@/components/board/StickerItem"; -import { ArrowLayer } from "./ArrowLayer"; +import { ARROW_HEIGHT, ArrowLayer } from "./ArrowLayer"; import { PhotoCard } from "./PhotoCard"; import { ShapeCard } from "./ShapeCard"; +const CANVAS_MIN_HEIGHT = 460; +const BOTTOM_ROOM = 120; + export function FreeformCanvas({ page }: { page: Page }) { const addArrow = useNotebooks((s) => s.addArrow); const selectedId = useUi((s) => s.selectedId); @@ -38,21 +42,32 @@ export function FreeformCanvas({ page }: { page: Page }) { const isEmpty = page.shapes.length === 0 && page.photos.length === 0 && page.stickers.length === 0; + // 置いたものの一番下に合わせて紙を伸ばす。下にはいつも書き足せる余白を残す。 const minHeight = useMemo(() => { let maxY = 0; - for (const s of page.shapes) maxY = Math.max(maxY, s.y + (heights[s.id] ?? 120)); + for (const s of page.shapes) maxY = Math.max(maxY, s.y + (heights[s.id] ?? UNMEASURED_SHAPE_HEIGHT)); for (const p of page.photos) maxY = Math.max(maxY, p.y + (heights[p.id] ?? p.w)); for (const s of page.stickers) maxY = Math.max(maxY, s.y + s.size); - return Math.max(460, maxY + 120); - }, [page.shapes, page.photos, page.stickers, heights]); + // 手で動かした矢印は図形から位置が離れるので、別途いちばん下を見る。 + // (回転を考えて、真下を向いた最悪ケースの長さで見積もる) + for (const a of page.arrows) { + if (a.manual) maxY = Math.max(maxY, a.my + a.length / 2 + ARROW_HEIGHT); + } + return Math.max(CANVAS_MIN_HEIGHT, maxY + BOTTOM_ROOM); + }, [page.shapes, page.photos, page.stickers, page.arrows, heights]); return ( setCanvasW(e.nativeEvent.layout.width)}> - select(null)} /> + select(null)} + accessibilityRole="button" + accessibilityLabel="選択を解除" + /> {isEmpty ? ( - ここにテキストや写真、シールを{"\n"}自由に置いてみてね🌸 + まっさらな1ページ。{"\n"}下の道具からテキスト・写真・シールを置いてみてね🌸 ) : null} @@ -95,7 +110,7 @@ export function FreeformCanvas({ page }: { page: Page }) { ))} {connectMode ? ( - + {connectFromId ? "つなぎ先のテキストをタップ🔗" : "つなぎたいテキストを2つ順にタップ🔗"} @@ -111,28 +126,25 @@ const styles = StyleSheet.create({ }, hint: { position: "absolute", - top: 40, - left: 24, - right: 24, + top: 44, + left: space.xl, + right: space.xl, textAlign: "center", - opacity: 0.4, - fontFamily: fonts.display, - fontSize: 15, - color: colors.chicInk, - lineHeight: 24, + opacity: 0.45, + ...text.handwritten, + color: chic.ink, }, connectBadge: { position: "absolute", - top: 8, + top: space.sm, alignSelf: "center", backgroundColor: "rgba(58,58,58,0.85)", - borderRadius: 14, + borderRadius: radii.small, paddingVertical: 5, - paddingHorizontal: 12, + paddingHorizontal: space.md, }, connectText: { + ...text.caption, color: colors.white, - fontFamily: fonts.body, - fontSize: 11, }, }); diff --git a/src/components/freeform/PhotoCard.tsx b/src/components/freeform/PhotoCard.tsx index eba0384..1ac6ef0 100644 --- a/src/components/freeform/PhotoCard.tsx +++ b/src/components/freeform/PhotoCard.tsx @@ -1,14 +1,13 @@ import { useEffect, useState } from "react"; import { Image, StyleSheet } from "react-native"; import type { LayoutChangeEvent } from "react-native"; +import { chic, radii, shadows } from "@/lib/theme"; import { useNotebooks } from "@/state/notebooks"; import { PHOTO_MAX_WIDTH, PHOTO_MIN_WIDTH } from "@/lib/types"; import type { Photo } from "@/lib/types"; import { Transformable } from "@/components/transform/Transformable"; import type { TransformPatch } from "@/components/transform/Transformable"; -const CHIC_HANDLE = "#555"; - export function PhotoCard({ photo, selected, @@ -52,8 +51,10 @@ export function PhotoCard({ maxW={PHOTO_MAX_WIDTH} selected={selected} bodyDraggable - handleTint={CHIC_HANDLE} + handleTint={chic.handle} boundsWidth={boundsWidth} + label="写真" + deleteLabel="この写真をはがす" onSelect={onSelect} onChange={onChange} onDelete={() => deletePhoto(photo.id)} @@ -62,6 +63,7 @@ export function PhotoCard({ source={{ uri: photo.dataUrl }} style={[styles.img, { aspectRatio: ratio }]} resizeMode="cover" + accessibilityIgnoresInvertColors onLayout={(e: LayoutChangeEvent) => onMeasureHeight(photo.id, e.nativeEvent.layout.height)} /> @@ -72,7 +74,7 @@ const styles = StyleSheet.create({ img: { width: "100%", height: undefined, - borderRadius: 10, - boxShadow: "0 1px 6px rgba(0,0,0,0.14)", + borderRadius: radii.small, + boxShadow: shadows.chicCard, }, }); diff --git a/src/components/freeform/ShapeCard.tsx b/src/components/freeform/ShapeCard.tsx index 718362e..4133fa3 100644 --- a/src/components/freeform/ShapeCard.tsx +++ b/src/components/freeform/ShapeCard.tsx @@ -1,14 +1,14 @@ +import { useEffect, useRef } from "react"; import { StyleSheet, TextInput, View } from "react-native"; import type { LayoutChangeEvent } from "react-native"; -import { colors, fonts } from "@/lib/theme"; +import { chic, colors, radii, shadows, space, text } from "@/lib/theme"; import { useNotebooks } from "@/state/notebooks"; +import { useUi } from "@/state/ui"; import { SHAPE_MAX_WIDTH, SHAPE_MIN_WIDTH } from "@/lib/types"; import type { Shape } from "@/lib/types"; import { GRIP_RESERVE, Transformable } from "@/components/transform/Transformable"; import type { TransformPatch } from "@/components/transform/Transformable"; -const CHIC_HANDLE = "#555"; - export function ShapeCard({ shape, selected, @@ -31,9 +31,21 @@ export function ShapeCard({ const setShapeText = useNotebooks((s) => s.setShapeText); const updateShape = useNotebooks((s) => s.updateShape); const deleteShape = useNotebooks((s) => s.deleteShape); + const focusId = useUi((s) => s.focusId); + const setFocus = useUi((s) => s.setFocus); + const inputRef = useRef(null); + + // 「+テキスト」で置いたばかりのカードは、選択され次第そのまま書き始められるようにする。 + // 入力欄は選択中しか editable にならないので、選択が乗るのを待ってから focus する。 + useEffect(() => { + if (focusId !== shape.id || !selected) return; + setFocus(null); + inputRef.current?.focus(); + }, [focusId, shape.id, selected, setFocus]); const onChange = (patch: TransformPatch) => updateShape(shape.id, patch); const onLayout = (e: LayoutChangeEvent) => onMeasureHeight(shape.id, e.nativeEvent.layout.height); + const preview = shape.text.trim().slice(0, 20); return ( (connectMode ? onConnectTap(shape.id) : onSelect())} onChange={onChange} onDelete={() => deleteShape(shape.id)} > setShapeText(shape.id, t)} multiline editable={!connectMode && selected} placeholder="なんでも書いてね…" - placeholderTextColor="rgba(90,77,112,0.4)" + placeholderTextColor={colors.placeholder} + cursorColor={chic.ink} style={styles.text} /> @@ -70,23 +88,21 @@ export function ShapeCard({ const styles = StyleSheet.create({ card: { - backgroundColor: colors.white, - borderRadius: 12, + backgroundColor: chic.card, + borderRadius: radii.small, borderWidth: 1, - borderColor: "#eee", - padding: 10, - boxShadow: "0 1px 5px rgba(0,0,0,0.10)", + borderColor: chic.borderSoft, + padding: space.sm, + boxShadow: shadows.chicCard, }, pending: { borderWidth: 2, - borderColor: colors.chicLine, + borderColor: chic.rule, borderStyle: "dashed", }, text: { - fontFamily: fonts.body, - fontSize: 13, + ...text.body, color: colors.ink, - lineHeight: 19, padding: 0, minHeight: 20, }, diff --git a/src/components/notebook/NotebookSection.tsx b/src/components/notebook/NotebookSection.tsx index 32b490a..26e268d 100644 --- a/src/components/notebook/NotebookSection.tsx +++ b/src/components/notebook/NotebookSection.tsx @@ -1,19 +1,22 @@ import { StyleSheet, TextInput, View } from "react-native"; import Svg, { Defs, Line, Pattern, Rect } from "react-native-svg"; -import { colors, fonts } from "@/lib/theme"; +import { colors, text } from "@/lib/theme"; import { useNotebooks } from "@/state/notebooks"; import type { Page } from "@/lib/types"; +import { useSvgId } from "@/components/ui/Gradient"; const LINE_HEIGHT = 32; +const MIN_HEIGHT = 280; export function NotebookSection({ page }: { page: Page }) { const setNote = useNotebooks((s) => s.setNote); + const rule = useSvgId("noteRule"); return ( - + - + @@ -40,12 +44,11 @@ export function NotebookSection({ page }: { page: Page }) { const styles = StyleSheet.create({ wrap: { - minHeight: 280, + minHeight: MIN_HEIGHT, }, input: { - minHeight: 280, - fontFamily: fonts.body, - fontSize: 15, + minHeight: MIN_HEIGHT, + ...text.bodyL, lineHeight: LINE_HEIGHT, color: colors.ink, paddingHorizontal: 4, diff --git a/src/components/transform/Transformable.tsx b/src/components/transform/Transformable.tsx index c7df26b..b176fd2 100644 --- a/src/components/transform/Transformable.tsx +++ b/src/components/transform/Transformable.tsx @@ -3,7 +3,7 @@ import { useEffect } from "react"; import { Pressable, StyleSheet, Text, View } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import Animated, { measure, runOnJS, useAnimatedRef, useAnimatedStyle, useSharedValue } from "react-native-reanimated"; -import { colors } from "@/lib/theme"; +import { colors, shadows } from "@/lib/theme"; export interface TransformPatch { x?: number; @@ -12,6 +12,12 @@ export interface TransformPatch { rot?: number; } +interface SecondaryAction { + icon: string; + label: string; + onPress: () => void; +} + interface TransformableProps { x: number; y: number; @@ -28,6 +34,17 @@ interface TransformableProps { handleTint?: string; boundsWidth?: number | undefined; minY?: number; + // 位置と幅をキャンバスの内側へ丸めるか。回転した矢印のように「箱の左上」が + // 画面上の位置と一致しない要素は false にする(丸めると線が縮んで + // 図形どうしをつながなくなる)。 + bounded?: boolean; + // 読み上げ用の名前。「シール」「テキスト」など、何を掴んでいるかを伝える。 + label?: string; + // 中身をひとかたまりの読み上げ要素にまとめるか。編集できるテキストを含む + // 要素で true にすると、TalkBack が入力欄に降りられなくなるので false にする。 + bodyAccessible?: boolean; + deleteLabel?: string; + secondaryAction?: SecondaryAction; onSelect: () => void; onChange: (patch: TransformPatch) => void; onDelete?: () => void; @@ -36,6 +53,7 @@ interface TransformableProps { const HANDLE = 26; +// つまみ(⠿)を上にはみ出させる分だけ、上端に確保しておく余白。 export const GRIP_RESERVE = 15; function tintWithAlpha(hex: string): string { @@ -60,6 +78,11 @@ export function Transformable({ handleTint = colors.plum, boundsWidth, minY = 0, + bounded = true, + label, + bodyAccessible = true, + deleteLabel = "削除", + secondaryAction, onSelect, onChange, onDelete, @@ -79,13 +102,14 @@ export function Transformable({ const startAngle = useSharedValue(0); useEffect(() => { - const clampedW = boundsWidth !== undefined ? Math.max(minW, Math.min(w, boundsWidth)) : w; - const maxX = boundsWidth !== undefined ? Math.max(0, boundsWidth - clampedW) : Number.POSITIVE_INFINITY; - posX.value = Math.min(maxX, Math.max(0, x)); - posY.value = Math.max(minY, y); + const clampable = bounded && boundsWidth !== undefined; + const clampedW = clampable ? Math.max(minW, Math.min(w, boundsWidth)) : w; + const maxX = clampable ? Math.max(0, boundsWidth - clampedW) : Number.POSITIVE_INFINITY; + posX.value = bounded ? Math.min(maxX, Math.max(0, x)) : x; + posY.value = bounded ? Math.max(minY, y) : y; sw.value = clampedW; srot.value = rot; - }, [x, y, w, rot, boundsWidth, minW, minY, posX, posY, sw, srot]); + }, [x, y, w, rot, bounded, boundsWidth, minW, minY, posX, posY, sw, srot]); const animStyle = useAnimatedStyle(() => ({ transform: [{ translateX: posX.value }, { translateY: posY.value }, { rotate: `${srot.value}deg` }], @@ -100,9 +124,16 @@ export function Transformable({ startY.value = posY.value; }) .onUpdate((e) => { + const nextX = startX.value + e.translationX; + const nextY = startY.value + e.translationY; + if (!bounded) { + posX.value = nextX; + posY.value = nextY; + return; + } const maxX = boundsWidth !== undefined ? Math.max(0, boundsWidth - sw.value) : 1e6; - posX.value = Math.min(maxX, Math.max(0, startX.value + e.translationX)); - posY.value = Math.max(minY, startY.value + e.translationY); + posX.value = Math.min(maxX, Math.max(0, nextX)); + posY.value = Math.max(minY, nextY); }) .onEnd(() => { runOnJS(onChange)({ x: Math.round(posX.value), y: Math.round(posY.value) }); @@ -132,7 +163,7 @@ export function Transformable({ const cy = m.pageY + m.height / 2; const d = Math.hypot(e.absoluteX - cx, e.absoluteY - cy); let next = Math.max(minW, Math.min(maxW, Math.round((startW.value * d) / startDist.value))); - if (boundsWidth !== undefined) next = Math.min(next, Math.max(minW, boundsWidth - posX.value)); + if (bounded && boundsWidth !== undefined) next = Math.min(next, Math.max(minW, boundsWidth - posX.value)); sw.value = next; }) .onEnd(() => { @@ -163,12 +194,23 @@ export function Transformable({ return ( - {children} + + {children} + {showDragHandle ? ( - + @@ -176,7 +218,11 @@ export function Transformable({ {selected && rotatable ? ( - + @@ -184,14 +230,36 @@ export function Transformable({ {selected && resizable ? ( - + ) : null} + {selected && secondaryAction ? ( + + {secondaryAction.icon} + + ) : null} + {selected && onDelete ? ( - + ) : null} @@ -215,9 +283,9 @@ const styles = StyleSheet.create({ position: "absolute", left: "50%", marginLeft: -18, - top: -15, + top: -GRIP_RESERVE, width: 36, - height: 15, + height: GRIP_RESERVE, borderTopLeftRadius: 6, borderTopRightRadius: 6, alignItems: "center", @@ -237,7 +305,7 @@ const styles = StyleSheet.create({ borderWidth: 2, alignItems: "center", justifyContent: "center", - boxShadow: "0 2px 6px rgba(0,0,0,0.25)", + boxShadow: shadows.handle, }, rotateHandle: { left: -13, @@ -251,6 +319,25 @@ const styles = StyleSheet.create({ fontSize: 13, fontWeight: "700", }, + // 回転つまみ(左上)・削除(右上)・拡大つまみ(右下)とぶつからない角に置く。 + secondary: { + position: "absolute", + bottom: -10, + left: -10, + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: colors.white, + borderWidth: 1.5, + alignItems: "center", + justifyContent: "center", + zIndex: 5, + }, + secondaryText: { + fontSize: 12, + lineHeight: 14, + fontWeight: "700", + }, delete: { position: "absolute", top: -10, diff --git a/src/components/ui/Background.tsx b/src/components/ui/Background.tsx index c69fb86..7094897 100644 --- a/src/components/ui/Background.tsx +++ b/src/components/ui/Background.tsx @@ -1,12 +1,19 @@ import { StyleSheet, View, useWindowDimensions } from "react-native"; import Svg, { Defs, LinearGradient, RadialGradient, Rect, Stop } from "react-native-svg"; -import { colors } from "@/lib/theme"; +import { chic, colors } from "@/lib/theme"; +import { useSvgId } from "./Gradient"; -export function Background({ chic }: { chic: boolean }) { +// Web版の body 背景(斜めのグラデーション+3点のにじみ)をそのまま持ってきた壁紙。 +// ノート式を開いているあいだは、紙に集中できるよう無地のグレーに切り替える。 +export function Background({ chic: chicMode }: { chic: boolean }) { const { width, height } = useWindowDimensions(); + const base = useSvgId("bgBase"); + const lav = useSvgId("bgLav"); + const mint = useSvgId("bgMint"); + const rose = useSvgId("bgRose"); - if (chic) { - return ; + if (chicMode) { + return ; } const rMax = Math.max(width, height); @@ -15,28 +22,28 @@ export function Background({ chic }: { chic: boolean }) { - + - + - + - + - - - - + + + + ); diff --git a/src/components/ui/Gradient.tsx b/src/components/ui/Gradient.tsx new file mode 100644 index 0000000..7b057c3 --- /dev/null +++ b/src/components/ui/Gradient.tsx @@ -0,0 +1,26 @@ +import { useId } from "react"; +import { StyleSheet } from "react-native"; +import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; + +// react-native-svg の の id はビュー階層で衝突しうるので、 +// 同じ画面に複数のグラデーションが出ても混ざらないように毎回ユニーク化する。 +export function useSvgId(prefix: string): string { + return `${prefix}${useId().replace(/[^a-zA-Z0-9]/g, "")}`; +} + +// 親いっぱいに斜めのグラデーションを敷く。親側に overflow:"hidden" と +// borderRadius を付けておけば角丸のボタン背景としてそのまま使える。 +export function GradientFill({ from, to }: { from: string; to: string }) { + const id = useSvgId("grad"); + return ( + + + + + + + + + + ); +} diff --git a/src/components/ui/PromptModal.tsx b/src/components/ui/PromptModal.tsx index 5c9a9ad..e17b017 100644 --- a/src/components/ui/PromptModal.tsx +++ b/src/components/ui/PromptModal.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { Modal, Pressable, StyleSheet, Text, View } from "react-native"; -import { colors, fonts, radii } from "@/lib/theme"; +import { colors, radii, space, text } from "@/lib/theme"; import { AppButton, AppTextInput } from "./kit"; export function PromptModal({ @@ -31,8 +31,7 @@ export function PromptModal({ return ( - {} - + {title} onSubmit(value.trim())} style={styles.input} /> @@ -71,20 +72,19 @@ const styles = StyleSheet.create({ maxWidth: 360, backgroundColor: colors.paper, borderRadius: radii.panel, - padding: 18, + padding: space.lg + 2, }, title: { - fontFamily: fonts.display, - fontSize: 16, + ...text.displayS, color: colors.plum, - marginBottom: 12, + marginBottom: space.md, }, input: { - marginBottom: 14, + marginBottom: space.md + 2, }, row: { flexDirection: "row", - gap: 10, + gap: space.sm + 2, }, flex: { flex: 1, diff --git a/src/components/ui/SparkleLayer.tsx b/src/components/ui/SparkleLayer.tsx index c28349b..946708e 100644 --- a/src/components/ui/SparkleLayer.tsx +++ b/src/components/ui/SparkleLayer.tsx @@ -3,6 +3,7 @@ import { StyleSheet, View, useWindowDimensions } from "react-native"; import Animated, { Easing, useAnimatedStyle, + useReducedMotion, useSharedValue, withDelay, withRepeat, @@ -10,13 +11,16 @@ import Animated, { } from "react-native-reanimated"; import { StickerShape } from "./StickerShape"; +const TWINKLES = 18; +const CYCLE_MS = 2600; + function Twinkle({ left, top, size, delay }: { left: number; top: number; size: number; delay: number }) { const progress = useSharedValue(0); useEffect(() => { progress.value = withDelay( delay, - withRepeat(withTiming(1, { duration: 2600, easing: Easing.inOut(Easing.ease) }), -1, false), + withRepeat(withTiming(1, { duration: CYCLE_MS, easing: Easing.inOut(Easing.ease) }), -1, false), ); }, [progress, delay]); @@ -36,16 +40,20 @@ function Twinkle({ left, top, size, delay }: { left: number; top: number; size: ); } +// 画面全体にきらめきを散らす環境演出。 +// Web版が prefers-reduced-motion を尊重しているのと同じく、端末の +// 「視差効果を減らす」設定が入っていたら静かな点だけにする。 export function SparkleLayer({ active }: { active: boolean }) { const { width, height } = useWindowDimensions(); + const reduced = useReducedMotion(); const seeds = useMemo( () => - Array.from({ length: 18 }, (_, i) => ({ + Array.from({ length: TWINKLES }, (_, i) => ({ key: i, left: Math.random() * width, top: Math.random() * height, size: 8 + Math.random() * 10, - delay: Math.random() * 2600, + delay: Math.random() * CYCLE_MS, })), [width, height], ); @@ -54,9 +62,15 @@ export function SparkleLayer({ active }: { active: boolean }) { return ( - {seeds.map((s) => ( - - ))} + {seeds.map((s) => + reduced ? ( + + + + ) : ( + + ), + )} ); } @@ -65,4 +79,7 @@ const styles = StyleSheet.create({ twinkle: { position: "absolute", }, + still: { + opacity: 0.5, + }, }); diff --git a/src/components/ui/StickerShape.tsx b/src/components/ui/StickerShape.tsx index 99ceb36..523816d 100644 --- a/src/components/ui/StickerShape.tsx +++ b/src/components/ui/StickerShape.tsx @@ -1,6 +1,12 @@ -import Svg, { Circle, Ellipse, G, Path } from "react-native-svg"; +import type { ReactElement } from "react"; +import Svg, { Circle, Ellipse, G, Line, Path, Rect } from "react-native-svg"; +import { palette } from "@/lib/theme"; import type { StickerType } from "@/lib/types"; +// シールの線に使う色(塗りの c1/c2 とは別に固定で持つぶん)。 +const OUTLINE = palette.white; +const ANTENNA = "#7A6A90"; + interface StickerShapeProps { type: StickerType; size: number; @@ -8,59 +14,167 @@ interface StickerShapeProps { c2?: string; } -const DEFAULTS: Record = { - star: { c1: "#F4D58D", c2: "#ffffff" }, - flower: { c1: "#E8B4BC", c2: "#F4D58D" }, - heart: { c1: "#E8B4BC", c2: "#E8B4BC" }, - ribbon: { c1: "#C9B6E4", c2: "#fff8f2" }, - sparkle: { c1: "#F4D58D", c2: "#F4D58D" }, -}; - -export function StickerShape({ type, size, c1, c2 }: StickerShapeProps) { - const d = DEFAULTS[type]; - const fill = c1 ?? d.c1; - const accent = c2 ?? d.c2; +// シール1種類ぶんの定義。既定色と描画をここにまとめておくと、トレイ・盤面・ +// 紙吹雪・飾り枠のどこから呼んでも同じ絵が出る。 +interface StickerDef { + label: string; + c1: string; + c2: string; + draw: (c1: string, c2: string) => ReactElement; +} - return ( - - {type === "star" ? ( +const STICKERS: Record = { + star: { + label: "星", + c1: palette.gold, + c2: palette.white, + draw: (c1, c2) => ( + + ), + }, + flower: { + label: "花", + c1: palette.rose, + c2: palette.gold, + draw: (c1, c2) => ( + + + + + + + + + + + + ), + }, + heart: { + label: "ハート", + c1: palette.rose, + c2: palette.rose, + draw: (c1) => ( + + ), + }, + ribbon: { + label: "リボン", + c1: palette.lavender, + c2: "#FFF8F2", + draw: (c1, c2) => ( + + + + + + ), + }, + sparkle: { + label: "きらめき", + c1: palette.gold, + c2: palette.gold, + draw: (c1) => ( + + ), + }, + cloud: { + label: "くも", + c1: palette.white, + c2: "#B8D8F0", + draw: (c1, c2) => ( + + + + + + + ), + }, + moon: { + label: "三日月", + c1: palette.gold, + c2: palette.gold, + draw: (c1) => ( + + - ) : null} - - {type === "flower" ? ( - - - - - - - - - - - - ) : null} - - {type === "heart" ? ( - - ) : null} + + ), + }, + crown: { + label: "王冠", + c1: palette.gold, + c2: palette.rose, + draw: (c1, c2) => ( + + + + + + + + ), + }, + butterfly: { + label: "ちょうちょ", + c1: palette.lavender, + c2: palette.rose, + draw: (c1, c2) => ( + + + + + + + + ), + }, + paw: { + label: "にくきゅう", + c1: palette.rose, + c2: palette.rose, + draw: (c1) => ( + + + + + + + + ), + }, + gem: { + label: "宝石", + c1: palette.mint, + c2: palette.white, + draw: (c1, c2) => ( + + + + + + ), + }, +}; - {type === "ribbon" ? ( - - - - - - ) : null} +export function stickerLabel(type: StickerType): string { + return STICKERS[type].label; +} - {type === "sparkle" ? ( - - ) : null} +export function StickerShape({ type, size, c1, c2 }: StickerShapeProps) { + const def = STICKERS[type]; + return ( + + {def.draw(c1 ?? def.c1, c2 ?? def.c2)} ); } diff --git a/src/components/ui/Toast.tsx b/src/components/ui/Toast.tsx index 4a29923..57ec64d 100644 --- a/src/components/ui/Toast.tsx +++ b/src/components/ui/Toast.tsx @@ -1,19 +1,27 @@ import { StyleSheet, Text, View } from "react-native"; import Animated, { FadeInDown, FadeOutDown } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { colors, fonts } from "@/lib/theme"; +import { colors, radii, space, text, TRAY_BASE_HEIGHT } from "@/lib/theme"; import { useUi } from "@/state/ui"; export function Toast() { const toast = useUi((s) => s.toast); const insets = useSafeAreaInsets(); - if (!toast) return null; + // 入れ物は出しっぱなしにして、中身だけ差し替える。読み上げ領域ごと + // 出し入れすると Android には「新しく現れた」としか伝わらず、 + // 知らせが読み上げられないことがあるため。 return ( - - - {toast} - + + {toast ? ( + + {toast} + + ) : null} ); } @@ -27,17 +35,16 @@ const styles = StyleSheet.create({ zIndex: 300, }, toast: { - maxWidth: "80%", + maxWidth: "84%", backgroundColor: colors.plum, - borderRadius: 16, - paddingVertical: 7, - paddingHorizontal: 16, + borderRadius: radii.card, + paddingVertical: 8, + paddingHorizontal: space.lg, }, text: { + ...text.label, color: colors.white, opacity: 0.96, - fontFamily: fonts.body, - fontSize: 12, textAlign: "center", }, }); diff --git a/src/components/ui/WashiTape.tsx b/src/components/ui/WashiTape.tsx new file mode 100644 index 0000000..e907011 --- /dev/null +++ b/src/components/ui/WashiTape.tsx @@ -0,0 +1,75 @@ +import { StyleSheet, View } from "react-native"; +import type { StyleProp, ViewStyle } from "react-native"; +import Svg, { ClipPath, Defs, G, Line, Path } from "react-native-svg"; +import { useSvgId } from "./Gradient"; + +// --------------------------------------------------------------------------- +// マスキングテープ +// --------------------------------------------------------------------------- +// このアプリの「らしさ」を一手に引き受ける飾り。手帳デコの主役の道具なので、 +// 装飾として撒くのではなく《表紙の名前を留める》という役目のある1か所だけで使う。 +// 端は少しギザギザに、色は半透明にして、重なると濃くなる本物の質感に寄せている。 + +const TEAR = 2.2; + +function tornPath(w: number, h: number): string { + const q = h / 4; + return [ + `M0 0`, + `L${w} 0`, + `L${w - TEAR} ${q}`, + `L${w} ${q * 2}`, + `L${w - TEAR} ${q * 3}`, + `L${w} ${h}`, + `L0 ${h}`, + `L${TEAR} ${q * 3}`, + `L0 ${q * 2}`, + `L${TEAR} ${q}`, + `Z`, + ].join(" "); +} + +export function WashiTape({ + width, + height = 20, + color, + stripe, + rotate = -2, + style, +}: { + width: number; + height?: number; + color: string; + stripe: string; + rotate?: number; + style?: StyleProp; +}) { + const clipId = useSvgId("tape"); + const stripeCount = Math.ceil(width / 9) + 2; + + return ( + + + + + + + + + + {Array.from({ length: stripeCount }, (_, i) => { + const x = i * 9 - height; + return ; + })} + + + + ); +} + +const styles = StyleSheet.create({ + wrap: { + alignItems: "center", + justifyContent: "center", + }, +}); diff --git a/src/components/ui/kit.tsx b/src/components/ui/kit.tsx index f1b7506..94d0068 100644 --- a/src/components/ui/kit.tsx +++ b/src/components/ui/kit.tsx @@ -1,8 +1,10 @@ +import type { ReactNode } from "react"; import { Pressable, StyleSheet, Text, TextInput, View } from "react-native"; import type { StyleProp, TextInputProps, TextStyle, ViewStyle } from "react-native"; -import { colors, fonts, radii } from "@/lib/theme"; +import { chic, colors, HIT_TARGET, radii, shadows, space, text } from "@/lib/theme"; +import { GradientFill } from "./Gradient"; -export function Card({ children, style }: { children: React.ReactNode; style?: StyleProp }) { +export function Card({ children, style }: { children: ReactNode; style?: StyleProp }) { return {children}; } @@ -17,22 +19,27 @@ export function AppButton({ onPress, variant = "soft", disabled = false, + accessibilityLabel, style, }: { title: string; onPress: () => void; variant?: ButtonVariant; disabled?: boolean; + accessibilityLabel?: string; style?: StyleProp; }) { + const primary = variant === "primary"; return ( [ styles.btn, - variant === "primary" && styles.btnPrimary, + primary && styles.btnPrimary, variant === "soft" && styles.btnSoft, variant === "dashed" && styles.btnDashed, variant === "danger" && styles.btnDanger, @@ -40,15 +47,99 @@ export function AppButton({ pressed && styles.btnPressed, style, ]} + > + {/* Web版の「ラベンダー→ローズ」のグラデーションボタンを SVG で再現する */} + {primary ? : null} + + {title} + + + ); +} + +// 絵文字ひとつだけの丸ボタン。見た目が小さくてもタップ判定は最低 32dp を確保し、 +// 読み上げ用のラベルを必ず要求する。 +export function IconButton({ + icon, + label, + onPress, + size = HIT_TARGET, + tone = "veil", + tint, + disabled = false, + style, +}: { + icon: string; + label: string; + onPress: () => void; + size?: number; + tone?: "veil" | "rose" | "plain"; + // 絵文字そのものの色。ノート式の落ち着いた画面では墨色に寄せる。 + tint?: string; + disabled?: boolean; + style?: StyleProp; +}) { + return ( + [ + styles.iconBtn, + { width: size, height: size, borderRadius: size / 2 }, + tone === "veil" && styles.iconBtnVeil, + tone === "rose" && styles.iconBtnRose, + disabled && styles.btnDisabled, + pressed && styles.btnPressed, + style, + ]} + > + + {icon} + + + ); +} + +// 選択状態を持つ小さなチップ(飾り枠・罫線などの切り替え)。 +export function Chip({ + label, + active, + onPress, + chicMode = false, + style, +}: { + label: string; + active: boolean; + onPress: () => void; + chicMode?: boolean; + style?: StyleProp; +}) { + return ( + [ + styles.chip, + chicMode ? styles.chipChic : null, + active ? (chicMode ? styles.chipChicActive : styles.chipActive) : null, + pressed && styles.btnPressed, + style, + ]} > - {title} + {label} ); @@ -58,9 +149,9 @@ export function AppTextInput(props: TextInputProps & { style?: StyleProp @@ -69,25 +160,26 @@ export function AppTextInput(props: TextInputProps & { style?: StyleProp { - const json = JSON.stringify(state, null, 2); - const uri = writeTextFile(`kawaii-techo-backup-${todayStamp()}.json`, json); + const json = JSON.stringify(buildBackup(state, appVersion(), new Date().toISOString()), null, 2); + const uri = writeTextFile(`kawaii-techo-backup-${fileStamp()}.json`, json); if (await Sharing.isAvailableAsync()) { await Sharing.shareAsync(uri, { mimeType: "application/json", dialogTitle: "バックアップを保存" }); } } -export type ImportResult = { status: "picked"; state: AppState } | { status: "canceled" } | { status: "invalid" }; +export type ImportResult = + { status: "picked"; state: AppState } | { status: "canceled" } | { status: "rejected"; reason: BackupRejection }; export async function pickBackup(): Promise { const result = await DocumentPicker.getDocumentAsync({ @@ -28,12 +35,8 @@ export async function pickBackup(): Promise { try { parsed = JSON.parse(readTextFile(asset.uri)); } catch { - return { status: "invalid" }; - } - if (!parsed || typeof parsed !== "object" || !Array.isArray((parsed as { notebooks?: unknown }).notebooks)) { - return { status: "invalid" }; + return { status: "rejected", reason: "notBackup" }; } - const state = normalizeState(parsed); - if (state.notebooks.length === 0) return { status: "invalid" }; - return { status: "picked", state }; + const decoded = parseBackup(parsed); + return decoded.ok ? { status: "picked", state: decoded.state } : { status: "rejected", reason: decoded.reason }; } diff --git a/src/lib/backupFormat.test.ts b/src/lib/backupFormat.test.ts new file mode 100644 index 0000000..3b05b7a --- /dev/null +++ b/src/lib/backupFormat.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { BACKUP_FORMAT, BACKUP_FORMAT_VERSION, buildBackup, parseBackup } from "./backupFormat"; +import { newNotebook } from "./model"; +import type { AppState } from "./types"; + +function sampleState(): AppState { + const nb = newNotebook("profile", "テスト帳", "#C9B6E4"); + return { activeNotebookId: nb.id, notebooks: [nb] }; +} + +describe("buildBackup", () => { + it("封筒に形式・バージョン・アプリ版・日時を入れる", () => { + const state = sampleState(); + const file = buildBackup(state, "0.3.0", "2026-07-25T00:00:00.000Z"); + expect(file.format).toBe(BACKUP_FORMAT); + expect(file.formatVersion).toBe(BACKUP_FORMAT_VERSION); + expect(file.appVersion).toBe("0.3.0"); + expect(file.exportedAt).toBe("2026-07-25T00:00:00.000Z"); + expect(file.state.notebooks).toHaveLength(1); + }); +}); + +describe("parseBackup", () => { + it("自分が書き出した封筒は読める", () => { + const file = buildBackup(sampleState(), "0.3.0", "2026-07-25T00:00:00.000Z"); + const result = parseBackup(JSON.parse(JSON.stringify(file))); + expect(result.ok).toBe(true); + if (result.ok) expect(result.state.notebooks[0]?.name).toBe("テスト帳"); + }); + + // Web版(index.html)の書き出しは封筒を持たない素の AppState なので、ここで弾かれる。 + it("Web版の素の JSON は「バックアップではない」として弾く", () => { + const webExport = { activeNotebookId: null, notebooks: [{ id: "nb1", pages: [{ id: "pg1" }] }] }; + expect(parseBackup(webExport)).toEqual({ ok: false, reason: "notBackup" }); + }); + + it("オブジェクト以外・配列・null は弾く", () => { + expect(parseBackup(null).ok).toBe(false); + expect(parseBackup([]).ok).toBe(false); + expect(parseBackup("{}").ok).toBe(false); + }); + + it("形式名が違えば弾く", () => { + expect(parseBackup({ format: "something-else", formatVersion: 1, state: {} })).toEqual({ + ok: false, + reason: "notBackup", + }); + }); + + it("将来のバージョンは tooNew として弾く", () => { + const file = buildBackup(sampleState(), "9.9.9", "2026-07-25T00:00:00.000Z"); + expect(parseBackup({ ...file, formatVersion: BACKUP_FORMAT_VERSION + 1 })).toEqual({ ok: false, reason: "tooNew" }); + }); + + it("バージョンが数値でなければ tooNew として弾く", () => { + const file = buildBackup(sampleState(), "0.3.0", "2026-07-25T00:00:00.000Z"); + expect(parseBackup({ ...file, formatVersion: "1" })).toEqual({ ok: false, reason: "tooNew" }); + }); + + it("手帳が1冊も無ければ empty", () => { + const file = buildBackup({ activeNotebookId: null, notebooks: [] }, "0.3.0", "2026-07-25T00:00:00.000Z"); + expect(parseBackup(file)).toEqual({ ok: false, reason: "empty" }); + }); + + it("中身は normalizeState を通すので壊れた値も直る", () => { + const result = parseBackup({ + format: BACKUP_FORMAT, + formatVersion: 1, + state: { + activeNotebookId: "missing", + notebooks: [{ id: "nb1", type: "bogus", pages: [{ id: "pg1", frame: "bogus" }] }], + }, + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.state.activeNotebookId).toBeNull(); + expect(result.state.notebooks[0]?.type).toBe("profile"); + expect(result.state.notebooks[0]?.pages[0]?.frame).toBe("aurora"); + } + }); +}); diff --git a/src/lib/backupFormat.ts b/src/lib/backupFormat.ts new file mode 100644 index 0000000..717cd79 --- /dev/null +++ b/src/lib/backupFormat.ts @@ -0,0 +1,52 @@ +import { normalizeState } from "./model"; +import type { AppState } from "./types"; + +// バックアップファイルの中身の型。0.3.0 で「素の AppState をそのまま JSON に +// する」形をやめ、封筒(format / formatVersion)で包む形にした。 +// +// これにより Web版(index.html)が書き出す JSON — 封筒のない +// { activeNotebookId, notebooks } — は読み込めなくなる。Android版は独自の +// 保存形式として歩むことにしたので、Web版との相互読み込みは意図的に打ち切っている。 +export const BACKUP_FORMAT = "kawaii-deconote-android"; +export const BACKUP_FORMAT_VERSION = 1; + +export interface BackupEnvelope { + format: string; + formatVersion: number; + appVersion: string; + exportedAt: string; + state: AppState; +} + +export function buildBackup(state: AppState, appVersion: string, exportedAt: string): BackupEnvelope { + return { + format: BACKUP_FORMAT, + formatVersion: BACKUP_FORMAT_VERSION, + appVersion, + exportedAt, + state, + }; +} + +export type BackupRejection = "notBackup" | "tooNew" | "empty"; + +export type BackupParseResult = { ok: true; state: AppState } | { ok: false; reason: BackupRejection }; + +// 読み込んだ JSON をバックアップとして解釈する。封筒が違えば読まずに弾く。 +export function parseBackup(raw: unknown): BackupParseResult { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return { ok: false, reason: "notBackup" }; + const envelope = raw as Partial; + if (envelope.format !== BACKUP_FORMAT) return { ok: false, reason: "notBackup" }; + if (typeof envelope.formatVersion !== "number" || envelope.formatVersion > BACKUP_FORMAT_VERSION) { + return { ok: false, reason: "tooNew" }; + } + const state = normalizeState(envelope.state); + if (state.notebooks.length === 0) return { ok: false, reason: "empty" }; + return { ok: true, state }; +} + +export const BACKUP_REJECTION_MESSAGE: Record = { + notBackup: "⚠️ このファイルは「かわいくデコれる手帳」のバックアップではないみたい", + tooNew: "⚠️ 新しいバージョンのバックアップです。アプリを更新してね", + empty: "⚠️ 手帳が1冊も入っていないバックアップでした", +}; diff --git a/src/lib/capture.ts b/src/lib/capture.ts index 1a5f78a..ba2e2a0 100644 --- a/src/lib/capture.ts +++ b/src/lib/capture.ts @@ -2,21 +2,22 @@ import { File, Paths } from "expo-file-system"; import * as Sharing from "expo-sharing"; import { captureRef } from "react-native-view-shot"; import type { Component } from "react"; - -function safeName(title: string): string { - const trimmed = title.trim().replace(/[\\/:*?"<>|]/g, "_"); - return trimmed || "techo"; -} +import { fileStamp, safeFileName } from "./format"; export async function captureAndShare(ref: React.RefObject, title: string): Promise { const shot = await captureRef(ref, { format: "png", quality: 1, result: "tmpfile" }); + // 共有シートに出るファイル名を「ページ名-日時.png」にしたいだけなので、 + // 同名がすでにある場合やコピーに失敗した場合は、元の一時ファイルを + // そのまま共有する。既存ファイルを消しにいかないのは、前回の共有が + // まだそのファイルを開いている可能性があるため。 let shareUri = shot; try { - const dest = new File(Paths.cache, `${safeName(title)}.png`); - if (dest.exists) dest.delete(); - new File(shot).copy(dest); - shareUri = dest.uri; + const dest = new File(Paths.cache, `${safeFileName(title, "techo")}-${fileStamp()}.png`); + if (!dest.exists) { + new File(shot).copy(dest); + shareUri = dest.uri; + } } catch { shareUri = shot; } diff --git a/src/lib/color.test.ts b/src/lib/color.test.ts new file mode 100644 index 0000000..73febe2 --- /dev/null +++ b/src/lib/color.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { darken, isLightColor, lighten, mix, relativeLuminance } from "./color"; + +describe("mix", () => { + it("t=0 なら a、t=1 なら b", () => { + expect(mix("#000000", "#FFFFFF", 0)).toBe("#000000"); + expect(mix("#000000", "#FFFFFF", 1)).toBe("#FFFFFF"); + }); + + it("中間は成分ごとに補間する", () => { + expect(mix("#000000", "#FFFFFF", 0.5)).toBe("#808080"); + }); + + it("t は 0〜1 に丸める", () => { + expect(mix("#000000", "#FFFFFF", -3)).toBe("#000000"); + expect(mix("#000000", "#FFFFFF", 9)).toBe("#FFFFFF"); + }); + + it("3桁の短縮表記も読む", () => { + expect(mix("#fff", "#fff", 0.5)).toBe("#FFFFFF"); + }); + + it("8桁(アルファ付き)はアルファを無視する", () => { + expect(mix("#FF000080", "#FF000080", 0)).toBe("#FF0000"); + }); + + it("解釈できない色は a をそのまま返す", () => { + expect(mix("red", "#FFFFFF", 0.5)).toBe("red"); + expect(mix("#FFFFFF", "rebeccapurple", 0.5)).toBe("#FFFFFF"); + expect(mix("#GGGGGG", "#FFFFFF", 0.5)).toBe("#GGGGGG"); + expect(mix("#12", "#FFFFFF", 0.5)).toBe("#12"); + }); +}); + +describe("lighten / darken", () => { + it("白・黒へ寄せる", () => { + expect(lighten("#808080", 1)).toBe("#FFFFFF"); + expect(darken("#808080", 1)).toBe("#000000"); + }); + + it("0 なら元の色", () => { + expect(lighten("#C9B6E4", 0)).toBe("#C9B6E4"); + }); +}); + +describe("relativeLuminance / isLightColor", () => { + it("白は1、黒は0", () => { + expect(relativeLuminance("#FFFFFF")).toBeCloseTo(1); + expect(relativeLuminance("#000000")).toBeCloseTo(0); + }); + + it("淡い表紙色は「明るい」、濃い色は「暗い」", () => { + expect(isLightColor("#F4D58D")).toBe(true); + expect(isLightColor("#CFCFCF")).toBe(true); + expect(isLightColor("#5B4D70")).toBe(false); + }); + + it("解釈できない色は明るい扱い(白文字を避ける安全側)", () => { + expect(relativeLuminance("nope")).toBe(1); + expect(isLightColor("nope")).toBe(true); + }); + + it("暗い成分はガンマ補正の下側の枝を通る", () => { + expect(relativeLuminance("#010101")).toBeGreaterThan(0); + expect(relativeLuminance("#010101")).toBeLessThan(0.01); + }); +}); diff --git a/src/lib/color.ts b/src/lib/color.ts new file mode 100644 index 0000000..efbb2b9 --- /dev/null +++ b/src/lib/color.ts @@ -0,0 +1,60 @@ +import { clamp } from "./format"; + +type Rgb = [number, number, number]; + +// #abc / #aabbcc / #aabbccdd を受け取る(アルファは無視する)。 +// 解釈できない文字列は null。 +function toRgb(hex: string): Rgb | null { + const raw = hex.trim().replace(/^#/, ""); + const body = raw.length === 3 || raw.length === 4 ? raw.slice(0, 3) : raw.length >= 6 ? raw.slice(0, 6) : null; + if (body === null || !/^[0-9a-fA-F]+$/.test(body)) return null; + const pairs = + body.length === 3 + ? [body[0]! + body[0]!, body[1]! + body[1]!, body[2]! + body[2]!] + : [body.slice(0, 2), body.slice(2, 4), body.slice(4, 6)]; + return pairs.map((p) => parseInt(p, 16)) as Rgb; +} + +function toHex(rgb: Rgb): string { + return `#${rgb + .map((v) => + Math.round(clamp(v, 0, 255)) + .toString(16) + .padStart(2, "0"), + ) + .join("")}`.toUpperCase(); +} + +// a と b を t(0=a, 1=b)で混ぜる。解釈できない色は a をそのまま返す。 +export function mix(a: string, b: string, t: number): string { + const from = toRgb(a); + const to = toRgb(b); + if (!from || !to) return a; + const amount = clamp(t, 0, 1); + return toHex([0, 1, 2].map((i) => from[i]! + (to[i]! - from[i]!) * amount) as Rgb); +} + +export function lighten(hex: string, t: number): string { + return mix(hex, "#FFFFFF", t); +} + +export function darken(hex: string, t: number): string { + return mix(hex, "#000000", t); +} + +// WCAG の相対輝度(0=黒, 1=白)。解釈できない色は「明るい」とみなす。 +export function relativeLuminance(hex: string): number { + const rgb = toRgb(hex); + if (!rgb) return 1; + const [r, g, b] = rgb.map((v) => { + const c = v / 255; + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }) as Rgb; + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +// 表紙の色は白〜黄〜灰まで幅があるので、その上に白文字を載せてよいかを +// 輝度で判断する(淡い色の上の白文字は読めないため)。 +export function isLightColor(hex: string): boolean { + return relativeLuminance(hex) > 0.5; +} diff --git a/src/lib/format.test.ts b/src/lib/format.test.ts index 2016b39..e3837fb 100644 --- a/src/lib/format.test.ts +++ b/src/lib/format.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { clamp, degrees, fileStamp, pad2, randomOf, todayStamp } from "./format"; +import { clamp, degrees, fileStamp, pad2, randomBetween, randomOf, safeFileName, todayStamp } from "./format"; describe("pad2", () => { it("2桁未満はゼロ埋めする", () => { @@ -81,3 +81,37 @@ describe("degrees", () => { expect(degrees(0)).toBe(0); }); }); + +describe("randomBetween", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("min 以上 max 未満に収まる", () => { + vi.spyOn(Math, "random").mockReturnValue(0); + expect(randomBetween(10, 20)).toBe(10); + vi.spyOn(Math, "random").mockReturnValue(0.5); + expect(randomBetween(10, 20)).toBe(15); + }); + + it("max が min 以下でも min を返す(幅は負にしない)", () => { + vi.spyOn(Math, "random").mockReturnValue(0.9); + expect(randomBetween(30, 10)).toBe(30); + }); +}); + +describe("safeFileName", () => { + it("ファイル名に使えない文字を _ にする", () => { + expect(safeFileName('a/b:c*d?e"fh|i', "techo")).toBe("a_b_c_d_e_f_g_h_i"); + }); + + it("前後の空白は落とす", () => { + expect(safeFileName(" ページ ", "techo")).toBe("ページ"); + }); + + it("空・空白のみ・ドットだけなら fallback", () => { + expect(safeFileName("", "techo")).toBe("techo"); + expect(safeFileName(" ", "techo")).toBe("techo"); + expect(safeFileName("..", "techo")).toBe("techo"); + }); +}); diff --git a/src/lib/format.ts b/src/lib/format.ts index a10eee1..4d111db 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -12,6 +12,7 @@ export function todayStamp(): string { return `${year}-${month}-${day}`; } +// 同じ日に何度書き出してもファイル名がぶつからないよう、分まで入れる。 export function fileStamp(): string { const now = new Date(); const { year, month, day } = dateParts(now); @@ -23,6 +24,11 @@ export function randomOf(arr: readonly T[]): T | undefined { return arr[Math.floor(Math.random() * arr.length)]; } +// min 以上 max 未満の乱数。シールやテキストを「だいたいこのへん」に置くのに使う。 +export function randomBetween(min: number, max: number): number { + return min + Math.random() * Math.max(0, max - min); +} + export function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } @@ -30,3 +36,12 @@ export function clamp(value: number, min: number, max: number): number { export function degrees(radians: number): number { return (radians * 180) / Math.PI; } + +// ファイル名に使えない文字を落とす。空になったら fallback を使う。 +export function safeFileName(raw: string, fallback: string): string { + const trimmed = raw + .trim() + .replace(/[\\/:*?"<>|]/g, "_") + .replace(/^\.+$/, ""); + return trimmed || fallback; +} diff --git a/src/lib/haptics.ts b/src/lib/haptics.ts new file mode 100644 index 0000000..1e82eec --- /dev/null +++ b/src/lib/haptics.ts @@ -0,0 +1,17 @@ +import * as Haptics from "expo-haptics"; + +// 端末が振動に対応していない/権限がない場合は静かに何もしない。 +// 触覚は「できたら気持ちいい」程度の演出なので、失敗しても操作は止めない。 +function safely(run: () => Promise): void { + void run().catch(() => {}); +} + +// シールを貼る・コピーするなど、ものが増えたときの軽いトン。 +export function tapFeedback(): void { + safely(() => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)); +} + +// 工程にチェックが付いたときの「できた!」。 +export function successFeedback(): void { + safely(() => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)); +} diff --git a/src/lib/model.test.ts b/src/lib/model.test.ts index 6f17879..c73a543 100644 --- a/src/lib/model.test.ts +++ b/src/lib/model.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "vitest"; import { appendToBranch, - findStepInTree, + copyName, + duplicateNotebook, + duplicatePage, + moveStepInTree, newId, newIfStep, newNotebook, @@ -14,9 +17,10 @@ import { pageDisplayTitle, removeStepFromTree, seedUid, + stepProgress, updateStepInTree, } from "./model"; -import type { IfStep, NormalStep, Step } from "./types"; +import type { IfStep, NormalStep, Notebook, Page, Step } from "./types"; // --- テスト用の工程ツリー・ビルダー --- const plain = (id: string, text = "", done = false): NormalStep => ({ id, type: "step", text, done }); @@ -152,22 +156,7 @@ describe("removeStepFromTree", () => { const tree: Step[] = [ifStep("if1", [ifStep("if2", [plain("deep")])], [])]; const out = removeStepFromTree(tree, "if2"); expect((out[0] as IfStep).branches.yes).toHaveLength(0); - expect(findStepInTree(out, "deep")).toBeUndefined(); - }); -}); - -describe("findStepInTree", () => { - const tree: Step[] = [plain("a"), ifStep("if1", [plain("y1"), ifStep("if2", [plain("deep")])], [plain("n1")])]; - - it("トップレベル・ネスト・深いネストいずれも見つける", () => { - expect(findStepInTree(tree, "a")?.id).toBe("a"); - expect(findStepInTree(tree, "y1")?.id).toBe("y1"); - expect(findStepInTree(tree, "n1")?.id).toBe("n1"); - expect(findStepInTree(tree, "deep")?.id).toBe("deep"); - }); - - it("見つからなければ undefined", () => { - expect(findStepInTree(tree, "missing")).toBeUndefined(); + expect(JSON.stringify(out)).not.toContain("deep"); }); }); @@ -334,17 +323,18 @@ describe("normalizeNotebook", () => { expect(normalizeNotebook({ id: "nb1", pages: [] })).toBeNull(); }); - it("旧 rollbahn タイプは notestyle に移行する", () => { + // Web版だけが書き出していた旧タイプ名は、互換を打ち切ったので既定値に落ちる。 + it("知らない type は profile に落とす", () => { const nb = normalizeNotebook({ id: "nb1", type: "rollbahn", activePageId: "pg1", pages: [{ id: "pg1", steps: [] }], }); - expect(nb?.type).toBe("notestyle"); + expect(nb?.type).toBe("profile"); }); - it("notestyle では note をシェイプへ移し note を空にする", () => { + it("notestyle でも note はそのまま残す(シェイプへは動かさない)", () => { const nb = normalizeNotebook({ id: "nb1", type: "notestyle", @@ -352,8 +342,8 @@ describe("normalizeNotebook", () => { pages: [{ id: "pg1", type: "notebook", note: "メモ本文", shapes: [] }], }); const page = nb?.pages[0]; - expect(page?.note).toBe(""); - expect(page?.shapes.some((s) => s.text === "メモ本文")).toBe(true); + expect(page?.note).toBe("メモ本文"); + expect(page?.shapes).toHaveLength(0); }); it("activePageId が無効なら先頭ページに合わせる", () => { @@ -376,7 +366,7 @@ describe("normalizePage", () => { expect(p.type).toBe("flowchart"); expect(p.frame).toBe("aurora"); expect(p.ruleStyle).toBe("lines"); - expect(p.paperColor).toBe("#FBF7F2"); + expect(p.paperColor).toBe("#FFFFFF"); expect(p.steps).toEqual([]); }); @@ -432,12 +422,12 @@ describe("正規化の細部(クランプ・フォールバック・矢印)" expect(p.shapes[1]?.w).toBe(80); }); - it("写真の dataUrl は image フィールドからも拾う", () => { + // Web版だけが使っていた image フィールドは、互換を打ち切ったので読まない。 + it("dataUrl が無い写真は捨てる(image フィールドは見ない)", () => { const p = normalizePage({ photos: [{ id: "ph1", x: 0, y: 0, w: 140, rot: 0, image: "data:image/jpeg;base64,BBBB" }], }); - expect(p.photos).toHaveLength(1); - expect(p.photos[0]?.dataUrl).toBe("data:image/jpeg;base64,BBBB"); + expect(p.photos).toHaveLength(0); }); it("frame / ruleStyle / type が不正なら既定値に落とす", () => { @@ -445,7 +435,7 @@ describe("正規化の細部(クランプ・フォールバック・矢印)" expect(p.type).toBe("flowchart"); expect(p.frame).toBe("aurora"); expect(p.ruleStyle).toBe("lines"); - expect(p.paperColor).toBe("#FBF7F2"); + expect(p.paperColor).toBe("#FFFFFF"); }); it("手動矢印は範囲内なら保持し、範囲外なら自動に戻す", () => { @@ -624,6 +614,178 @@ describe("id 採番と全要素の走査", () => { }); }); +describe("moveStepInTree", () => { + it("トップレベルの工程を前後に動かす", () => { + const tree: Step[] = [plain("a"), plain("b"), plain("c")]; + expect(moveStepInTree(tree, "b", -1).map((s) => s.id)).toEqual(["b", "a", "c"]); + expect(moveStepInTree(tree, "b", 1).map((s) => s.id)).toEqual(["a", "c", "b"]); + }); + + it("端では動かさない", () => { + const tree: Step[] = [plain("a"), plain("b")]; + expect(moveStepInTree(tree, "a", -1).map((s) => s.id)).toEqual(["a", "b"]); + expect(moveStepInTree(tree, "b", 1).map((s) => s.id)).toEqual(["a", "b"]); + }); + + it("分岐の中でも同じ並びの中だけで動く", () => { + const tree: Step[] = [ifStep("if1", [plain("y1"), plain("y2"), plain("y3")], [plain("n1")])]; + const out = moveStepInTree(tree, "y3", -1); + const top = out[0] as IfStep; + expect(top.branches.yes.map((s) => s.id)).toEqual(["y1", "y3", "y2"]); + expect(top.branches.no.map((s) => s.id)).toEqual(["n1"]); + }); + + it("入れ子の if カード自体も並べ替えられる", () => { + const tree: Step[] = [ifStep("if1", [plain("y1"), ifStep("if2")], [])]; + const out = moveStepInTree(tree, "if2", -1); + expect((out[0] as IfStep).branches.yes.map((s) => s.id)).toEqual(["if2", "y1"]); + }); + + it("見つからない id なら何も変わらない", () => { + const tree: Step[] = [plain("a"), ifStep("if1", [plain("y1")])]; + expect(moveStepInTree(tree, "zzz", 1).map((s) => s.id)).toEqual(["a", "if1"]); + }); +}); + +describe("copyName", () => { + it("「〜のコピー」を付ける", () => { + expect(copyName("メモ帳", 30)).toBe("メモ帳のコピー"); + }); + + it("上限を超えるときは元の名前を詰める", () => { + const long = "あ".repeat(30); + const out = copyName(long, 30); + expect(out).toHaveLength(30); + expect(out.endsWith("のコピー")).toBe(true); + }); + + it("上限が接尾辞より短くても接尾辞は残す", () => { + expect(copyName("あいうえお", 2)).toBe("のコピー"); + }); +}); + +describe("duplicatePage", () => { + const source = (): Page => ({ + ...newPage("flowchart", "元ページ"), + steps: [plain("s1", "工程"), ifStep("if1", [plain("y1")], [])], + stickers: [{ id: "st1", type: "star", x: 1, y: 2, rot: 3, size: 44 }], + shapes: [ + { id: "sh1", text: "A", x: 0, y: 0, w: 150, rot: 0 }, + { id: "sh2", text: "B", x: 10, y: 10, w: 150, rot: 0 }, + ], + photos: [{ id: "ph1", x: 0, y: 0, w: 140, rot: 0, dataUrl: "data:image/png;base64,AAAA" }], + arrows: [{ id: "ar1", from: "sh1", to: "sh2", manual: false, mx: 0, my: 0, length: 0, angle: 0 }], + }); + + it("中身は保ちつつ、id はすべて振り直す", () => { + const original = source(); + const copy = duplicatePage(original); + expect(copy.id).not.toBe(original.id); + expect(copy.steps[0]?.id).not.toBe("s1"); + expect((copy.steps[0] as NormalStep).text).toBe("工程"); + expect(copy.stickers[0]?.id).not.toBe("st1"); + expect(copy.shapes.map((s) => s.text)).toEqual(["A", "B"]); + expect(copy.shapes[0]?.id).not.toBe("sh1"); + expect(copy.photos[0]?.id).not.toBe("ph1"); + expect(copy.photos[0]?.dataUrl).toBe("data:image/png;base64,AAAA"); + }); + + it("ネストした分岐の工程まで振り直す", () => { + const copy = duplicatePage(source()); + const branch = (copy.steps[1] as IfStep).branches.yes[0]; + expect(branch?.id).not.toBe("y1"); + expect((copy.steps[1] as IfStep).id).not.toBe("if1"); + }); + + it("矢印は新しいシェイプ id へつなぎ直す", () => { + const copy = duplicatePage(source()); + const arrow = copy.arrows[0]; + expect(arrow?.from).toBe(copy.shapes[0]?.id); + expect(arrow?.to).toBe(copy.shapes[1]?.id); + }); + + it("参照先を失った矢印は落とす", () => { + const page: Page = { + ...newPage(), + shapes: [], + arrows: [{ id: "ar1", from: "ghost", to: "ghost2", manual: false, mx: 0, my: 0, length: 0, angle: 0 }], + }; + expect(duplicatePage(page).arrows).toHaveLength(0); + }); + + it("タイトルは「〜のコピー」、無題なら無題のまま", () => { + expect(duplicatePage(source()).title).toBe("元ページのコピー"); + expect(duplicatePage({ ...newPage(), title: "" }).title).toBe(""); + }); + + it("元のページは書き換えない", () => { + const original = source(); + duplicatePage(original); + expect(original.steps[0]?.id).toBe("s1"); + expect(original.shapes[0]?.id).toBe("sh1"); + }); +}); + +describe("duplicateNotebook", () => { + const source = (): Notebook => { + const first = { ...newPage("flowchart", "1"), id: "pg1" }; + const second = { ...newPage("flowchart", "2"), id: "pg2" }; + return { id: "nb1", name: "手帳", type: "profile", color: "#C9B6E4", activePageId: "pg2", pages: [first, second] }; + }; + + it("名前に「のコピー」を付けて、ページも全部複製する", () => { + const copy = duplicateNotebook(source()); + expect(copy.name).toBe("手帳のコピー"); + expect(copy.id).not.toBe("nb1"); + expect(copy.pages).toHaveLength(2); + expect(copy.pages.map((p) => p.id)).not.toContain("pg1"); + }); + + it("ページ名はそのまま(手帳ごとコピーなので中は変えない)", () => { + expect(duplicateNotebook(source()).pages.map((p) => p.title)).toEqual(["1", "2"]); + }); + + it("開いていたページの位置を引き継ぐ", () => { + const copy = duplicateNotebook(source()); + expect(copy.activePageId).toBe(copy.pages[1]?.id); + }); + + it("activePageId が壊れていても先頭ページに落とす", () => { + const copy = duplicateNotebook({ ...source(), activePageId: "missing" }); + expect(copy.activePageId).toBe(copy.pages[0]?.id); + }); + + it("無題の手帳は表示名からコピー名を作る", () => { + const copy = duplicateNotebook({ ...source(), name: "", type: "notestyle" }); + expect(copy.name).toBe("無題のノートのコピー"); + }); + + it("ページが1枚も無い(壊れた)手帳でも落ちない", () => { + const copy = duplicateNotebook({ ...source(), pages: [] }); + expect(copy.pages).toEqual([]); + expect(copy.activePageId).toBe("pg2"); + }); +}); + +describe("stepProgress", () => { + it("トップレベルと分岐の中を合わせて数える", () => { + const tree: Step[] = [ + plain("a", "", true), + plain("b"), + ifStep("if1", [plain("y1", "", true), plain("y2")], [plain("n1", "", true)]), + ]; + expect(stepProgress(tree)).toEqual({ done: 3, total: 5 }); + }); + + it("if カード自体は数に入れない", () => { + expect(stepProgress([ifStep("if1")])).toEqual({ done: 0, total: 0 }); + }); + + it("工程が無ければ 0/0", () => { + expect(stepProgress([])).toEqual({ done: 0, total: 0 }); + }); +}); + describe("表示名ヘルパー", () => { it("notebookDisplayName", () => { expect(notebookDisplayName({ name: "マイ帳", type: "profile" })).toBe("マイ帳"); diff --git a/src/lib/model.ts b/src/lib/model.ts index f18a35c..cbfbbe4 100644 --- a/src/lib/model.ts +++ b/src/lib/model.ts @@ -1,3 +1,4 @@ +import { clamp } from "./format"; import type { AppState, Arrow, @@ -18,7 +19,15 @@ import type { StickerType, } from "./types"; import { + DEFAULT_BRANCH_LABELS, + DEFAULT_FRAME, + DEFAULT_NOTEBOOK_COLOR, + DEFAULT_PAPER_COLOR, + DEFAULT_RULE_STYLE, FRAMES, + MAX_NOTEBOOK_NAME, + MAX_PAGE_TITLE, + MAX_POSITION, PHOTO_DEFAULT_WIDTH, PHOTO_MAX_WIDTH, PHOTO_MIN_WIDTH, @@ -67,12 +76,14 @@ export function seedUid(state: AppState): void { uid = max + 1; } +// --- ファクトリ --- + export function newPage(type: PageType = "flowchart", title = ""): Page { return { id: newId(), type, title, - frame: "aurora", + frame: DEFAULT_FRAME, sparkleOn: false, steps: [], stickers: [], @@ -80,12 +91,16 @@ export function newPage(type: PageType = "flowchart", title = ""): Page { shapes: [], photos: [], arrows: [], - ruleStyle: "lines", - paperColor: "#FFFFFF", + ruleStyle: DEFAULT_RULE_STYLE, + paperColor: DEFAULT_PAPER_COLOR, }; } -export function newNotebook(type: NotebookType = "profile", name = "", color = "#C9B6E4"): Notebook { +export function newNotebook( + type: NotebookType = "profile", + name = "", + color: string = DEFAULT_NOTEBOOK_COLOR, +): Notebook { const first = newPage("flowchart", ""); return { id: newId(), name, type, color, activePageId: first.id, pages: [first] }; } @@ -100,7 +115,7 @@ export function newIfStep(): IfStep { type: "if", text: "", done: false, - labels: { yes: "はい", no: "いいえ" }, + labels: { ...DEFAULT_BRANCH_LABELS }, branches: { yes: [], no: [] }, }; } @@ -144,18 +159,6 @@ export function removeStepFromTree(steps: Step[], id: string): Step[] { return result; } -// id が一致する工程を木のどこにあっても探して返す(見つからなければ undefined)。 -export function findStepInTree(steps: Step[], id: string): Step | undefined { - for (const s of steps) { - if (s.id === id) return s; - if (s.type === "if") { - const found = findStepInTree(s.branches.yes, id) ?? findStepInTree(s.branches.no, id); - if (found) return found; - } - } - return undefined; -} - // ifId の if 分岐(key 側)の末尾に child を追加した新しい配列を返す。 export function appendToBranch(steps: Step[], ifId: string, key: BranchKey, child: Step): Step[] { return updateStepInTree(steps, ifId, (s) => @@ -163,6 +166,103 @@ export function appendToBranch(steps: Step[], ifId: string, key: BranchKey, chil ); } +// id の工程を「同じ並びの中で」ひとつ前後に動かす。トップレベルでも分岐の中でも +// 動くので、UI 側は工程が木のどこにあるかを気にしなくていい。 +export function moveStepInTree(steps: Step[], id: string, dir: -1 | 1): Step[] { + const index = steps.findIndex((s) => s.id === id); + if (index >= 0) { + const target = index + dir; + const a = steps[index]; + const b = steps[target]; + if (!a || !b) return steps; // 端にいるので動かせない + const next = steps.slice(); + next[index] = b; + next[target] = a; + return next; + } + return steps.map((s) => + s.type === "if" + ? { + ...s, + branches: { + yes: moveStepInTree(s.branches.yes, id, dir), + no: moveStepInTree(s.branches.no, id, dir), + }, + } + : s, + ); +} + +// --- 複製(手帳コピー/ページコピー) --- + +const COPY_SUFFIX = "のコピー"; + +// 「〜のコピー」を付ける。入力欄の文字数上限を超えないように元の名前を詰める。 +export function copyName(base: string, maxLength: number): string { + const room = Math.max(0, maxLength - COPY_SUFFIX.length); + return `${base.length > room ? base.slice(0, room) : base}${COPY_SUFFIX}`; +} + +function cloneStep(step: Step): Step { + if (step.type === "if") { + return { + id: newId(), + type: "if", + text: step.text, + done: step.done, + labels: { ...step.labels }, + branches: { yes: step.branches.yes.map(cloneStep), no: step.branches.no.map(cloneStep) }, + }; + } + return { ...step, id: newId() }; +} + +// ページを丸ごと複製する。中の要素にはすべて新しい id を振り、矢印の参照も +// 新しいシェイプ id へ張り替える(元ページと id を共有しないので、あとから +// どちらを編集しても互いに影響しない)。 +export function duplicatePage( + page: Page, + title: string = page.title ? copyName(page.title, MAX_PAGE_TITLE) : "", +): Page { + const shapeIds = new Map(); + const shapes = page.shapes.map((s) => { + const id = newId(); + shapeIds.set(s.id, id); + return { ...s, id }; + }); + return { + ...page, + id: newId(), + title, + steps: page.steps.map(cloneStep), + stickers: page.stickers.map((s) => ({ ...s, id: newId() })), + shapes, + photos: page.photos.map((p) => ({ ...p, id: newId() })), + arrows: page.arrows.flatMap((a) => { + const from = shapeIds.get(a.from); + const to = shapeIds.get(a.to); + return from && to ? [{ ...a, id: newId(), from, to }] : []; + }), + }; +} + +// 手帳を丸ごと複製する。開いていたページの位置はコピー先でも引き継ぐ。 +export function duplicateNotebook(nb: Notebook): Notebook { + const activeIndex = nb.pages.findIndex((p) => p.id === nb.activePageId); + const pages = nb.pages.map((p) => duplicatePage(p, p.title)); + return { + ...nb, + id: newId(), + name: copyName(notebookDisplayName(nb), MAX_NOTEBOOK_NAME), + pages, + activePageId: pages[Math.max(0, activeIndex)]?.id ?? nb.activePageId, + }; +} + +// --- 取り込み時の正規化 --- +// 端末に保存した JSON も、書き出したバックアップも、必ずここを通してから +// 使う。壊れた値・想定外の型が入っていても既定値に落として読み進める。 + function rec(v: unknown): Record { return v && typeof v === "object" ? (v as Record) : {}; } @@ -173,10 +273,8 @@ function num(v: unknown, fallback = 0): number { return typeof v === "number" && Number.isFinite(v) ? v : fallback; } function clampNum(v: unknown, fallback: number, min: number, max: number): number { - return Math.min(max, Math.max(min, num(v, fallback))); + return clamp(num(v, fallback), min, max); } - -const MAX_POSITION = 20000; function bool(v: unknown, fallback = false): boolean { return typeof v === "boolean" ? v : fallback; } @@ -209,9 +307,11 @@ function normalizeStep(raw: unknown): Step { type: "if", text: str(r.text), done: bool(r.done), - labels: { yes: str(labels.yes, "はい"), no: str(labels.no, "いいえ") }, - // 分岐の中身も工程として再帰的に正規化する。 - // 旧データ(type なしの分岐工程 {id,text,done})は type:"step" の工程に変換される。 + labels: { + yes: str(labels.yes, DEFAULT_BRANCH_LABELS.yes), + no: str(labels.no, DEFAULT_BRANCH_LABELS.no), + }, + // 分岐の中身も工程として再帰的に正規化する(type なしの分岐工程は type:"step" になる)。 branches: { yes: list(branches.yes).map(normalizeStep), no: list(branches.no).map(normalizeStep), @@ -251,7 +351,7 @@ function normalizeShape(raw: unknown): Shape { function normalizePhoto(raw: unknown): Photo | null { const r = rec(raw); - const dataUrl = str(r.dataUrl) || str(r.image); + const dataUrl = str(r.dataUrl); if (!/^data:image\//i.test(dataUrl)) return null; return { ...normalizePlacement(r), @@ -260,8 +360,6 @@ function normalizePhoto(raw: unknown): Photo | null { }; } -const MANUAL_ARROW_MAX = 20000; - function normalizeArrow(raw: unknown): Arrow | null { const r = rec(raw); const from = str(r.from); @@ -272,12 +370,14 @@ function normalizeArrow(raw: unknown): Arrow | null { const my = num(r.my); const length = num(r.length); const angle = num(r.angle); + // 保存データ側の上限は MAX_POSITION でゆるく見る(画面上のリサイズ上限 + // ARROW_MAX_LENGTH より広い値が入っていても、位置情報として妥当なら残す)。 const manualOk = bool(r.manual) && length > 0 && - length <= MANUAL_ARROW_MAX && - Math.abs(mx) <= MANUAL_ARROW_MAX && - Math.abs(my) <= MANUAL_ARROW_MAX; + length <= MAX_POSITION && + Math.abs(mx) <= MAX_POSITION && + Math.abs(my) <= MAX_POSITION; return manualOk ? { id, from, to, manual: true, mx, my, length, angle } : { id, from, to, manual: false, mx: 0, my: 0, length: 0, angle: 0 }; @@ -291,7 +391,7 @@ export function normalizePage(raw: unknown): Page { id: idOf(r), type: oneOf(r.type, ["flowchart", "notebook"], "flowchart"), title: str(r.title), - frame: oneOf