Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 4 additions & 2 deletions web/src/components/FitLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, type ReactNode } from 'react'
import { useState, type ReactNode, type Ref } from 'react'

/**
* 計測する画面の配置。スクロールせずに1画面へ収める。
Expand All @@ -11,16 +11,18 @@ export function FitLayout({
cube,
side,
sideLabel = '記録',
ref,
}: {
className?: string
main: ReactNode
cube: ReactNode
side?: ReactNode
sideLabel?: string
ref?: Ref<HTMLDivElement>
}) {
const [open, setOpen] = useState(false)
return (
<div className={`fit-screen fit-layout ${open ? 'fit-layout--side-open' : ''} ${className}`}>
<div ref={ref} className={`fit-screen fit-layout ${open ? 'fit-layout--side-open' : ''} ${className}`}>
<div className="fit-main">{main}</div>
<div className="fit-cube">{cube}</div>
{side && (
Expand Down
62 changes: 62 additions & 0 deletions web/src/components/ScreenshotButton.tsx
Original file line number Diff line number Diff line change
@@ -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<Status, string> = {
idle: '画面をクリップボードにコピー',
busy: '画像にしています…',
copied: 'コピーしました',
downloaded: 'クリップボードに入れられないので、ダウンロードしました',
error: '画像にできませんでした',
}

/** target の画面を画像にしてクリップボードへ入れるボタン。ボタン自身は画像に入らない。 */
export function ScreenshotButton({ target }: { target: RefObject<HTMLElement | null> }) {
const [status, setStatus] = useState<Status>('idle')

useEffect(() => {
if (status === 'idle' || status === 'busy') return
const t = setTimeout(() => setStatus('idle'), 2000)
return () => clearTimeout(t)
}, [status])

return (
<button
{...{ [SKIP_ATTR]: '' }}
className="icon-button screenshot-button"
title={LABEL[status]}
aria-label={LABEL[status]}
disabled={status === 'busy'}
onClick={(e) => {
// Space の手動タイマーがボタンを押し直さないよう、フォーカスを外す
e.currentTarget.blur()
const el = target.current
if (!el) return
setStatus('busy')
copyScreenshot(el).then(setStatus, () => setStatus('error'))
}}
>
<svg
viewBox="0 0 24 24"
width="18"
height="18"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{status === 'copied' || status === 'downloaded' ? (
<path d="M5 12.5l4.5 4.5L19 7.5" />
) : (
<>
<path d="M4 8h3l2-3h6l2 3h3v11H4z" />
<circle cx="12" cy="13" r="3.5" />
</>
)}
</svg>
</button>
)
}
8 changes: 8 additions & 0 deletions web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,14 @@ code {
inset: 0;
}

/* 画面をクリップボードにコピーするボタン。キューブの右上に重ねる */
.screenshot-button {
position: absolute;
top: 4px;
right: 4px;
z-index: 1;
}

.fit-side-toggle {
display: none;
}
Expand Down
12 changes: 10 additions & 2 deletions web/src/pages/TimerPage.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
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'
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'
Expand Down Expand Up @@ -35,6 +36,7 @@ export function TimerPage() {
const state = useSessionState(session)
const { status } = useCubeState()
const debug = useDebug()
const screen = useRef<HTMLDivElement>(null)
const last = state?.last
const lastSplits = useMemo(
() => (last ? withSplits(store.kpuzzle, last, settings.cross_color).splits : undefined),
Expand All @@ -43,6 +45,7 @@ export function TimerPage() {

return (
<FitLayout
ref={screen}
className="timer-page"
main={
<>
Expand All @@ -66,7 +69,12 @@ export function TimerPage() {
{saveError && <p className="error">{saveError}</p>}
</>
}
cube={<CubeView />}
cube={
<>
<CubeView />
<ScreenshotButton target={screen} />
</>
}
sideLabel="直近の記録"
side={
<>
Expand Down
60 changes: 60 additions & 0 deletions web/src/ui/screenshot.ts
Original file line number Diff line number Diff line change
@@ -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<Blob> {
const scale = window.devicePixelRatio || 1
const players = [...el.querySelectorAll<TwistyPlayer>('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'
}
Loading