From 6c2fe9be595e0595e39b56654d760f22d4b6a437 Mon Sep 17 00:00:00 2001 From: capJavert Date: Tue, 4 Aug 2026 15:43:07 +0200 Subject: [PATCH] feat: add captureShareImage util for element-to-OG-image capture Captures any element (or React ref) via lazy-loaded snapdom and composes it onto a 1200x630 OG-sized PNG: contain-fit with theme-aware frame background and an optional branded bottom bar mirroring the app header (background-default fill, subtlest-tertiary top border, header logo rendered from LogoIcon/LogoText with theme color resolved at capture time). Dev builds expose window.captureShareImage for console testing. Co-Authored-By: Claude Fable 5 --- packages/shared/package.json | 1 + .../src/lib/imageShare/captureShareImage.ts | 185 ++++++++++++++++++ .../lib/imageShare/devCaptureShareImage.ts | 48 +++++ packages/webapp/pages/_app.tsx | 7 + pnpm-lock.yaml | 8 + 5 files changed, 249 insertions(+) create mode 100644 packages/shared/src/lib/imageShare/captureShareImage.ts create mode 100644 packages/shared/src/lib/imageShare/devCaptureShareImage.ts diff --git a/packages/shared/package.json b/packages/shared/package.json index 0813f35eda4..9085d7fff94 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -125,6 +125,7 @@ "@tiptap/extension-placeholder": "^3.22.5", "@tiptap/react": "^3.22.5", "@tiptap/starter-kit": "^3.22.5", + "@zumer/snapdom": "^2.23.1", "check-password-strength": "^2.0.10", "cmdk": "^1.0.0", "edge-aura": "0.6.0", diff --git a/packages/shared/src/lib/imageShare/captureShareImage.ts b/packages/shared/src/lib/imageShare/captureShareImage.ts new file mode 100644 index 00000000000..e31d68b2000 --- /dev/null +++ b/packages/shared/src/lib/imageShare/captureShareImage.ts @@ -0,0 +1,185 @@ +import type { RefObject } from 'react'; +import { createElement } from 'react'; +import type { SnapdomOptions } from '@zumer/snapdom'; +import LogoIcon from '../../svg/LogoIcon'; +import LogoText from '../../svg/LogoText'; + +export const SHARE_IMAGE_WIDTH = 1200; +export const SHARE_IMAGE_HEIGHT = 630; + +const LOGO_BAR_HEIGHT = 72; +const LOGO_BAR_BORDER = 2; +const LOGO_HEIGHT = 26; +const LOGO_GAP = 8; +const LOGO_ICON_RATIO = 35 / 20; +const LOGO_TEXT_RATIO = 77 / 20; + +export type CaptureTarget = HTMLElement | RefObject; + +export interface CaptureShareImageOptions extends SnapdomOptions { + padding?: number; + frameBackgroundColor?: string; + branded?: boolean; +} + +const TRANSPARENT = 'rgba(0, 0, 0, 0)'; + +const resolveFrameBackground = (): string => { + const rootStyle = getComputedStyle(document.documentElement); + const rootBackground = rootStyle.backgroundColor; + + if (rootBackground && rootBackground !== TRANSPARENT) { + return rootBackground; + } + + const themeBackground = rootStyle + .getPropertyValue('--theme-background-default') + .trim(); + + if (themeBackground) { + return themeBackground; + } + + return getComputedStyle(document.body).backgroundColor; +}; + +const svgToImage = async (markup: string): Promise => { + const image = new Image(); + image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup)}`; + await image.decode(); + + return image; +}; + +const drawLogoBar = async ( + context: CanvasRenderingContext2D, +): Promise => { + const { renderToStaticMarkup } = await import('react-dom/server'); + const rootStyle = getComputedStyle(document.documentElement); + const themeColor = rootStyle.getPropertyValue('--theme-text-primary').trim(); + const color = themeColor || getComputedStyle(document.body).color; + const barBackground = rootStyle + .getPropertyValue('--theme-background-default') + .trim(); + const barBorder = rootStyle + .getPropertyValue('--theme-border-subtlest-tertiary') + .trim(); + + const barTop = SHARE_IMAGE_HEIGHT - LOGO_BAR_HEIGHT; + + if (barBackground) { + context.fillStyle = barBackground; + context.fillRect(0, barTop, SHARE_IMAGE_WIDTH, LOGO_BAR_HEIGHT); + } + + if (barBorder) { + context.fillStyle = barBorder; + context.fillRect(0, barTop, SHARE_IMAGE_WIDTH, LOGO_BAR_BORDER); + } + + const toSizedMarkup = (markup: string, width: number): string => + markup + .replace(' { + const element = target instanceof HTMLElement ? target : target.current; + + if (!element) { + throw new Error('captureShareImage: target element is not mounted'); + } + + const { + padding = 48, + frameBackgroundColor, + branded = true, + ...snapOptions + } = options; + const barHeight = branded ? LOGO_BAR_HEIGHT : 0; + const contentWidth = SHARE_IMAGE_WIDTH - padding * 2; + const contentHeight = SHARE_IMAGE_HEIGHT - padding * 2 - barHeight; + + const rect = element.getBoundingClientRect(); + + if (!rect.width || !rect.height) { + throw new Error('captureShareImage: target element has no size'); + } + + const fitScale = Math.min( + contentWidth / rect.width, + contentHeight / rect.height, + ); + const captureScale = Math.max(1, fitScale); + + const { snapdom } = await import('@zumer/snapdom'); + const result = await snapdom(element, { + embedFonts: true, + scale: captureScale, + ...snapOptions, + }); + const source = await result.toCanvas(); + + const canvas = document.createElement('canvas'); + canvas.width = SHARE_IMAGE_WIDTH; + canvas.height = SHARE_IMAGE_HEIGHT; + const context = canvas.getContext('2d'); + + if (!context) { + throw new Error('captureShareImage: canvas 2d context unavailable'); + } + + context.fillStyle = frameBackgroundColor ?? resolveFrameBackground(); + context.fillRect(0, 0, canvas.width, canvas.height); + + const drawScale = Math.min( + contentWidth / source.width, + contentHeight / source.height, + ); + const drawWidth = source.width * drawScale; + const drawHeight = source.height * drawScale; + + context.imageSmoothingQuality = 'high'; + context.drawImage( + source, + (canvas.width - drawWidth) / 2, + padding + (contentHeight - drawHeight) / 2, + drawWidth, + drawHeight, + ); + + if (branded) { + await drawLogoBar(context); + } + + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error('captureShareImage: failed to encode PNG')); + } + }, 'image/png'); + }); +} diff --git a/packages/shared/src/lib/imageShare/devCaptureShareImage.ts b/packages/shared/src/lib/imageShare/devCaptureShareImage.ts new file mode 100644 index 00000000000..a2e2e1c681a --- /dev/null +++ b/packages/shared/src/lib/imageShare/devCaptureShareImage.ts @@ -0,0 +1,48 @@ +import type { + CaptureShareImageOptions, + CaptureTarget, +} from './captureShareImage'; +import { captureShareImage } from './captureShareImage'; + +export interface DevCaptureShareImageOptions extends CaptureShareImageOptions { + download?: boolean; + filename?: string; +} + +export async function devCaptureShareImage( + target: CaptureTarget, + options: DevCaptureShareImageOptions = {}, +): Promise { + const { + download = true, + filename = 'daily-capture', + ...captureOptions + } = options; + const blob = await captureShareImage(target, captureOptions); + + if (download) { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `${filename}.png`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + } + + return blob; +} + +declare global { + interface Window { + captureShareImage?: typeof devCaptureShareImage; + } +} + +export function installCaptureShareImage(): void { + if (typeof window === 'undefined' || window.captureShareImage) { + return; + } + + window.captureShareImage = devCaptureShareImage; +} diff --git a/packages/webapp/pages/_app.tsx b/packages/webapp/pages/_app.tsx index 37b178bab88..d9ff6a97e67 100644 --- a/packages/webapp/pages/_app.tsx +++ b/packages/webapp/pages/_app.tsx @@ -44,6 +44,7 @@ import { useThemedAsset } from '@dailydotdev/shared/src/hooks/utils'; import { DndContextProvider } from '@dailydotdev/shared/src/contexts/DndContext'; import { structuredCloneJsonPolyfill } from '@dailydotdev/shared/src/lib/structuredClone'; import { fromCDN } from '@dailydotdev/shared/src/lib'; +import { installCaptureShareImage } from '@dailydotdev/shared/src/lib/imageShare/devCaptureShareImage'; import { useOnboardingActions } from '@dailydotdev/shared/src/hooks/auth'; import { useCheckCoresRole } from '@dailydotdev/shared/src/hooks/useCheckCoresRole'; import { @@ -451,6 +452,12 @@ export default function App( useManualScrollRestoration(); useScrollbarWidth(); + useEffect(() => { + if (process.env.NODE_ENV === 'development') { + installCaptureShareImage(); + } + }, []); + const { Component, pageProps, router } = props; const { dehydratedState } = pageProps; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 77182c590a2..7f7ca038f49 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -445,6 +445,9 @@ importers: '@tiptap/starter-kit': specifier: ^3.22.5 version: 3.22.5 + '@zumer/snapdom': + specifier: ^2.23.1 + version: 2.23.1 check-password-strength: specifier: ^2.0.10 version: 2.0.10 @@ -4830,6 +4833,9 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + '@zumer/snapdom@2.23.1': + resolution: {integrity: sha512-r+6LRFXIpzTxlvGBjTr7mhvVW05TozgyE1QFmvO1hbchdbl4Km5/7DIkeyl2+nEVbz8Sp6/K2g4b86pXhCrFIQ==} + abab@2.0.6: resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} deprecated: Use your platform's native atob() and btoa() methods instead @@ -14251,6 +14257,8 @@ snapshots: '@xtuc/long@4.2.2': {} + '@zumer/snapdom@2.23.1': {} + abab@2.0.6: {} accepts@1.3.8: