kopia lustrzana https://github.com/Tldraw/Tldraw
275 wiersze
6.8 KiB
TypeScript
275 wiersze
6.8 KiB
TypeScript
import {
|
|
DefaultColorThemePalette,
|
|
DefaultFontFamilies,
|
|
DefaultFontStyle,
|
|
FileHelpers,
|
|
SvgExportDef,
|
|
TLDefaultFillStyle,
|
|
TLDefaultFontStyle,
|
|
TLShapeUtilCanvasSvgDef,
|
|
debugFlags,
|
|
useEditor,
|
|
} from '@tldraw/editor'
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { tldrawConstants } from '../../tldraw-constants'
|
|
import { useDefaultColorTheme } from './ShapeFill'
|
|
const { HASH_PATTERN_ZOOM_NAMES, MAX_ZOOM } = tldrawConstants
|
|
|
|
/** @public */
|
|
export function getFontDefForExport(fontStyle: TLDefaultFontStyle): SvgExportDef {
|
|
return {
|
|
key: `${DefaultFontStyle.id}:${fontStyle}`,
|
|
getElement: async () => {
|
|
const font = findFont(fontStyle)
|
|
if (!font) return null
|
|
|
|
const url: string = (font as any).$$_url
|
|
const fontFaceRule: string = (font as any).$$_fontface
|
|
if (!url || !fontFaceRule) return null
|
|
|
|
const fontFile = await (await fetch(url)).blob()
|
|
const base64FontFile = await FileHelpers.blobToDataUrl(fontFile)
|
|
|
|
const newFontFaceRule = fontFaceRule.replace(url, base64FontFile)
|
|
return <style>{newFontFaceRule}</style>
|
|
},
|
|
}
|
|
}
|
|
|
|
function findFont(name: TLDefaultFontStyle): FontFace | null {
|
|
const fontFamily = DefaultFontFamilies[name]
|
|
for (const font of document.fonts) {
|
|
if (fontFamily.includes(font.family)) {
|
|
return font
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
/** @public */
|
|
export function getFillDefForExport(fill: TLDefaultFillStyle): SvgExportDef {
|
|
return {
|
|
key: `${DefaultFontStyle.id}:${fill}`,
|
|
getElement: async () => {
|
|
if (fill !== 'pattern') return null
|
|
|
|
return <HashPatternForExport />
|
|
},
|
|
}
|
|
}
|
|
|
|
function HashPatternForExport() {
|
|
const theme = useDefaultColorTheme()
|
|
const t = 8 / 12
|
|
return (
|
|
<>
|
|
<mask id="hash_pattern_mask">
|
|
<rect x="0" y="0" width="8" height="8" fill="white" />
|
|
<g strokeLinecap="round" stroke="black">
|
|
<line x1={t * 1} y1={t * 3} x2={t * 3} y2={t * 1} />
|
|
<line x1={t * 5} y1={t * 7} x2={t * 7} y2={t * 5} />
|
|
<line x1={t * 9} y1={t * 11} x2={t * 11} y2={t * 9} />
|
|
</g>
|
|
</mask>
|
|
<pattern
|
|
id={HASH_PATTERN_ZOOM_NAMES[`1_${theme.id}`]}
|
|
width="8"
|
|
height="8"
|
|
patternUnits="userSpaceOnUse"
|
|
>
|
|
<rect x="0" y="0" width="8" height="8" fill={theme.solid} mask="url(#hash_pattern_mask)" />
|
|
</pattern>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export function getFillDefForCanvas(): TLShapeUtilCanvasSvgDef {
|
|
return {
|
|
key: `${DefaultFontStyle.id}:pattern`,
|
|
component: PatternFillDefForCanvas,
|
|
}
|
|
}
|
|
const TILE_PATTERN_SIZE = 8
|
|
|
|
const generateImage = (dpr: number, currentZoom: number, darkMode: boolean) => {
|
|
return new Promise<Blob>((resolve, reject) => {
|
|
const size = TILE_PATTERN_SIZE * currentZoom * dpr
|
|
|
|
const canvasEl = document.createElement('canvas')
|
|
canvasEl.width = size
|
|
canvasEl.height = size
|
|
|
|
const ctx = canvasEl.getContext('2d')
|
|
if (!ctx) return
|
|
|
|
ctx.fillStyle = darkMode ? '#212529' : '#f8f9fa'
|
|
ctx.fillRect(0, 0, size, size)
|
|
|
|
// This essentially generates an inverse of the pattern we're drawing.
|
|
ctx.globalCompositeOperation = 'destination-out'
|
|
|
|
ctx.lineCap = 'round'
|
|
ctx.lineWidth = 1.25 * currentZoom * dpr
|
|
|
|
const t = 8 / 12
|
|
const s = (v: number) => v * currentZoom * dpr
|
|
|
|
ctx.beginPath()
|
|
ctx.moveTo(s(t * 1), s(t * 3))
|
|
ctx.lineTo(s(t * 3), s(t * 1))
|
|
|
|
ctx.moveTo(s(t * 5), s(t * 7))
|
|
ctx.lineTo(s(t * 7), s(t * 5))
|
|
|
|
ctx.moveTo(s(t * 9), s(t * 11))
|
|
ctx.lineTo(s(t * 11), s(t * 9))
|
|
ctx.stroke()
|
|
|
|
canvasEl.toBlob((blob) => {
|
|
if (!blob || debugFlags.throwToBlob.get()) {
|
|
reject()
|
|
} else {
|
|
resolve(blob)
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
const canvasBlob = (size: [number, number], fn: (ctx: CanvasRenderingContext2D) => void) => {
|
|
const canvas = document.createElement('canvas')
|
|
canvas.width = size[0]
|
|
canvas.height = size[1]
|
|
const ctx = canvas.getContext('2d')
|
|
if (!ctx) return ''
|
|
fn(ctx)
|
|
return canvas.toDataURL()
|
|
}
|
|
type PatternDef = { zoom: number; url: string; darkMode: boolean }
|
|
|
|
const getDefaultPatterns = () => {
|
|
const defaultPatterns: PatternDef[] = []
|
|
for (let i = 1; i <= Math.ceil(MAX_ZOOM); i++) {
|
|
const whitePixelBlob = canvasBlob([1, 1], (ctx) => {
|
|
ctx.fillStyle = DefaultColorThemePalette.lightMode.black.semi
|
|
ctx.fillRect(0, 0, 1, 1)
|
|
})
|
|
const blackPixelBlob = canvasBlob([1, 1], (ctx) => {
|
|
ctx.fillStyle = DefaultColorThemePalette.darkMode.black.semi
|
|
ctx.fillRect(0, 0, 1, 1)
|
|
})
|
|
defaultPatterns.push({
|
|
zoom: i,
|
|
url: whitePixelBlob,
|
|
darkMode: false,
|
|
})
|
|
defaultPatterns.push({
|
|
zoom: i,
|
|
url: blackPixelBlob,
|
|
darkMode: true,
|
|
})
|
|
}
|
|
return defaultPatterns
|
|
}
|
|
|
|
function usePattern() {
|
|
const editor = useEditor()
|
|
const dpr = editor.getInstanceState().devicePixelRatio
|
|
const [isReady, setIsReady] = useState(false)
|
|
const defaultPatterns = useMemo(() => getDefaultPatterns(), [])
|
|
const [backgroundUrls, setBackgroundUrls] = useState<PatternDef[]>(defaultPatterns)
|
|
|
|
useEffect(() => {
|
|
if (process.env.NODE_ENV === 'test') {
|
|
setIsReady(true)
|
|
return
|
|
}
|
|
|
|
const promises: Promise<{ zoom: number; url: string; darkMode: boolean }>[] = []
|
|
|
|
for (let i = 1; i <= Math.ceil(MAX_ZOOM); i++) {
|
|
promises.push(
|
|
generateImage(dpr, i, false).then((blob) => ({
|
|
zoom: i,
|
|
url: URL.createObjectURL(blob),
|
|
darkMode: false,
|
|
}))
|
|
)
|
|
promises.push(
|
|
generateImage(dpr, i, true).then((blob) => ({
|
|
zoom: i,
|
|
url: URL.createObjectURL(blob),
|
|
darkMode: true,
|
|
}))
|
|
)
|
|
}
|
|
|
|
let isCancelled = false
|
|
Promise.all(promises).then((urls) => {
|
|
if (isCancelled) return
|
|
setBackgroundUrls(urls)
|
|
setIsReady(true)
|
|
})
|
|
|
|
return () => {
|
|
isCancelled = true
|
|
setIsReady(false)
|
|
}
|
|
}, [dpr])
|
|
|
|
const defs = (
|
|
<>
|
|
{backgroundUrls.map((item) => {
|
|
const key = item.zoom + (item.darkMode ? '_dark' : '_light')
|
|
return (
|
|
<pattern
|
|
key={key}
|
|
id={HASH_PATTERN_ZOOM_NAMES[key]}
|
|
width={TILE_PATTERN_SIZE}
|
|
height={TILE_PATTERN_SIZE}
|
|
patternUnits="userSpaceOnUse"
|
|
>
|
|
<image href={item.url} width={TILE_PATTERN_SIZE} height={TILE_PATTERN_SIZE} />
|
|
</pattern>
|
|
)
|
|
})}
|
|
</>
|
|
)
|
|
|
|
return { defs, isReady }
|
|
}
|
|
|
|
function PatternFillDefForCanvas() {
|
|
const editor = useEditor()
|
|
const containerRef = useRef<SVGGElement>(null)
|
|
const { defs, isReady } = usePattern()
|
|
|
|
useEffect(() => {
|
|
if (isReady && editor.environment.isSafari) {
|
|
const htmlLayer = findHtmlLayerParent(containerRef.current!)
|
|
if (htmlLayer) {
|
|
// Wait for `patternContext` to be picked up
|
|
requestAnimationFrame(() => {
|
|
htmlLayer.style.display = 'none'
|
|
|
|
// Wait for 'display = "none"' to take effect
|
|
requestAnimationFrame(() => {
|
|
htmlLayer.style.display = ''
|
|
})
|
|
})
|
|
}
|
|
}
|
|
}, [editor, isReady])
|
|
|
|
return (
|
|
<g ref={containerRef} data-testid={isReady ? 'ready-pattern-fill-defs' : undefined}>
|
|
{defs}
|
|
</g>
|
|
)
|
|
}
|
|
|
|
function findHtmlLayerParent(element: Element): HTMLElement | null {
|
|
if (element.classList.contains('tl-html-layer')) return element as HTMLElement
|
|
if (element.parentElement) return findHtmlLayerParent(element.parentElement)
|
|
return null
|
|
}
|