From f52d98df505f7226e19c5ed0617c7ecea71ab67a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=82=E3=83=BC=E3=82=8B?= Date: Sun, 1 Nov 2026 14:35:32 +0900 Subject: [PATCH] =?UTF-8?q?=E3=82=BF=E3=82=A4=E3=83=9E=E3=83=BC=E7=94=BB?= =?UTF-8?q?=E9=9D=A2=E3=82=92=E7=94=BB=E5=83=8F=E3=81=AB=E3=81=97=E3=81=A6?= =?UTF-8?q?=E3=82=AF=E3=83=AA=E3=83=83=E3=83=97=E3=83=9C=E3=83=BC=E3=83=89?= =?UTF-8?q?=E3=81=B8=E3=82=B3=E3=83=94=E3=83=BC=E3=81=99=E3=82=8B=E3=83=9C?= =?UTF-8?q?=E3=82=BF=E3=83=B3=E3=82=92=E8=B6=B3=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/package-lock.json | 7 +++ web/package.json | 1 + web/src/components/FitLayout.tsx | 6 ++- web/src/components/ScreenshotButton.tsx | 62 +++++++++++++++++++++++++ web/src/index.css | 8 ++++ web/src/pages/TimerPage.tsx | 12 ++++- web/src/ui/screenshot.ts | 60 ++++++++++++++++++++++++ 7 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 web/src/components/ScreenshotButton.tsx create mode 100644 web/src/ui/screenshot.ts diff --git a/web/package-lock.json b/web/package-lock.json index ab6f6e9..270605e 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,6 +11,7 @@ "@fontsource/biz-udpgothic": "^5.3.0", "@fontsource/saira-semi-condensed": "^5.3.0", "cubing": "^0.66.1", + "modern-screenshot": "^4.7.0", "react": "^19.3.1", "react-dom": "^19.3.1", "react-markdown": "^10.1.0", @@ -2776,6 +2777,12 @@ ], "license": "MIT" }, + "node_modules/modern-screenshot": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/modern-screenshot/-/modern-screenshot-4.7.0.tgz", + "integrity": "sha512-9YxN+ddPSMMlhylOv25VHzXrl9u67QRxoh7+SEewGtgUw7t6hHTrjptSDJUSne9oG4Xk/h2cwG15nIt4Hc9ujg==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/web/package.json b/web/package.json index cee7086..79294fc 100644 --- a/web/package.json +++ b/web/package.json @@ -17,6 +17,7 @@ "@fontsource/biz-udpgothic": "^5.3.0", "@fontsource/saira-semi-condensed": "^5.3.0", "cubing": "^0.66.1", + "modern-screenshot": "^4.7.0", "react": "^19.3.1", "react-dom": "^19.3.1", "react-markdown": "^10.1.0", diff --git a/web/src/components/FitLayout.tsx b/web/src/components/FitLayout.tsx index bbc7557..cd3b5a5 100644 --- a/web/src/components/FitLayout.tsx +++ b/web/src/components/FitLayout.tsx @@ -1,4 +1,4 @@ -import { useState, type ReactNode } from 'react' +import { useState, type ReactNode, type Ref } from 'react' /** * 計測する画面の配置。スクロールせずに1画面へ収める。 @@ -11,16 +11,18 @@ export function FitLayout({ cube, side, sideLabel = '記録', + ref, }: { className?: string main: ReactNode cube: ReactNode side?: ReactNode sideLabel?: string + ref?: Ref }) { const [open, setOpen] = useState(false) return ( -
+
{main}
{cube}
{side && ( diff --git a/web/src/components/ScreenshotButton.tsx b/web/src/components/ScreenshotButton.tsx new file mode 100644 index 0000000..b6af42d --- /dev/null +++ b/web/src/components/ScreenshotButton.tsx @@ -0,0 +1,62 @@ +import { useEffect, useState, type RefObject } from 'react' +import { copyScreenshot, SKIP_ATTR } from '../ui/screenshot' + +type Status = 'idle' | 'busy' | 'copied' | 'downloaded' | 'error' + +const LABEL: Record = { + idle: '画面をクリップボードにコピー', + busy: '画像にしています…', + copied: 'コピーしました', + downloaded: 'クリップボードに入れられないので、ダウンロードしました', + error: '画像にできませんでした', +} + +/** target の画面を画像にしてクリップボードへ入れるボタン。ボタン自身は画像に入らない。 */ +export function ScreenshotButton({ target }: { target: RefObject }) { + const [status, setStatus] = useState('idle') + + useEffect(() => { + if (status === 'idle' || status === 'busy') return + const t = setTimeout(() => setStatus('idle'), 2000) + return () => clearTimeout(t) + }, [status]) + + return ( + + ) +} diff --git a/web/src/index.css b/web/src/index.css index e0f9a03..85f9735 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -438,6 +438,14 @@ code { inset: 0; } +/* 画面をクリップボードにコピーするボタン。キューブの右上に重ねる */ +.screenshot-button { + position: absolute; + top: 4px; + right: 4px; + z-index: 1; +} + .fit-side-toggle { display: none; } diff --git a/web/src/pages/TimerPage.tsx b/web/src/pages/TimerPage.tsx index 2816573..143cc3a 100644 --- a/web/src/pages/TimerPage.tsx +++ b/web/src/pages/TimerPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react' +import { useCallback, useMemo, useRef } from 'react' import { CubeView } from '../components/CubeView' import { FitLayout } from '../components/FitLayout' import { GyroDebug } from '../components/GyroDebug' @@ -6,6 +6,7 @@ import { ManualTimer } from '../components/ManualTimer' import { MockControls } from '../components/MockControls' import { MoveLog } from '../components/MoveLog' import { RecentSolves } from '../components/RecentSolves' +import { ScreenshotButton } from '../components/ScreenshotButton' import { ScrambleDisplay } from '../components/ScrambleDisplay' import { StageRibbon } from '../components/StageRibbon' import { TimerDisplay } from '../components/TimerDisplay' @@ -35,6 +36,7 @@ export function TimerPage() { const state = useSessionState(session) const { status } = useCubeState() const debug = useDebug() + const screen = useRef(null) const last = state?.last const lastSplits = useMemo( () => (last ? withSplits(store.kpuzzle, last, settings.cross_color).splits : undefined), @@ -43,6 +45,7 @@ export function TimerPage() { return ( @@ -66,7 +69,12 @@ export function TimerPage() { {saveError &&

{saveError}

} } - cube={} + cube={ + <> + + + + } sideLabel="直近の記録" side={ <> diff --git a/web/src/ui/screenshot.ts b/web/src/ui/screenshot.ts new file mode 100644 index 0000000..dd8578e --- /dev/null +++ b/web/src/ui/screenshot.ts @@ -0,0 +1,60 @@ +// 画面の一部を画像にして、クリップボードへ入れる(入れられないブラウザではダウンロードする)。 +import type { TwistyPlayer } from 'cubing/twisty' +import { domToCanvas } from 'modern-screenshot' + +/** この属性を付けた要素(撮るボタンなど)は画像に入れない。 */ +export const SKIP_ATTR = 'data-screenshot-skip' + +/** el を見えている通りに PNG にする。3D キューブ(WebGL)は DOM からは写らないので、cubing に描かせて同じ位置に重ねる。 */ +export async function captureElement(el: HTMLElement): Promise { + const scale = window.devicePixelRatio || 1 + const players = [...el.querySelectorAll('twisty-player')] + const canvas = await domToCanvas(el, { + scale, + backgroundColor: getComputedStyle(document.body).backgroundColor, + filter: (node) => !(node instanceof Element && (node.hasAttribute(SKIP_ATTR) || node.localName === 'twisty-player')), + }) + const ctx = canvas.getContext('2d') + if (ctx) { + const origin = el.getBoundingClientRect() + for (const player of players) { + const r = player.getBoundingClientRect() + if (r.width === 0 || r.height === 0) continue + // cubing の screenshot は縦の画角が固定で、縦長の枠だと左右が切れる。短い辺の正方形で描いて真ん中に置く + const side = Math.min(r.width, r.height) + const url = await player.experimentalScreenshot({ width: Math.round(side * scale), height: Math.round(side * scale) }) + const img = new Image() + img.src = url + await img.decode() + const x = r.left - origin.left + (r.width - side) / 2 + const y = r.top - origin.top + (r.height - side) / 2 + ctx.drawImage(img, x * scale, y * scale, side * scale, side * scale) + } + } + return new Promise((resolve, reject) => + canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('画像にできませんでした'))), 'image/png'), + ) +} + +/** + * el の画像をクリップボードへ入れる。copied=クリップボードへ入れた、downloaded=入れられないのでファイルにした。 + * Safari はクリック中に clipboard.write を呼ぶ必要があるので、画像の Promise のまま渡す。 + */ +export async function copyScreenshot(el: HTMLElement): Promise<'copied' | 'downloaded'> { + const blob = captureElement(el) + if (navigator.clipboard?.write && typeof ClipboardItem !== 'undefined') { + try { + await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]) + return 'copied' + } catch { + // 権限がないなど。下でダウンロードに切り替える + } + } + const url = URL.createObjectURL(await blob) + const a = document.createElement('a') + a.href = url + a.download = `cubalyst-${new Date().toISOString().replace(/[:.]/g, '-')}.png` + a.click() + setTimeout(() => URL.revokeObjectURL(url), 1000) + return 'downloaded' +}