diff --git a/app/PreviewShape/PreviewShape.tsx b/app/PreviewShape/PreviewShape.tsx index b82a078b..40d8c699 100644 --- a/app/PreviewShape/PreviewShape.tsx +++ b/app/PreviewShape/PreviewShape.tsx @@ -1,4 +1,5 @@ /* eslint-disable react-hooks/rules-of-hooks */ +import { ReactElement, useEffect } from 'react' import { BaseBoxShapeUtil, DefaultSpinner, @@ -12,9 +13,9 @@ import { useToasts, useValue, } from 'tldraw' -import { useEffect } from 'react' import { Dropdown } from '../components/Dropdown' import { LINK_HOST, PROTOCOL } from '../lib/hosts' +import { getSandboxPermissions } from '../lib/iframe' import { uploadLink } from '../lib/uploadLink' export type PreviewShape = TLBaseShape< @@ -47,7 +48,6 @@ export class PreviewShapeUtil extends BaseBoxShapeUtil { override isAspectRatioLocked = (_shape: PreviewShape) => false override canResize = (_shape: PreviewShape) => true override canBind = (_shape: PreviewShape) => false - override canUnmount = () => false override component(shape: PreviewShape) { const isEditing = useIsEditing(shape.id) @@ -124,6 +124,23 @@ export class PreviewShapeUtil extends BaseBoxShapeUtil { border: '1px solid var(--color-panel-contrast)', borderRadius: 'var(--radius-2)', }} + allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" + sandbox={getSandboxPermissions({ + 'allow-downloads-without-user-activation': false, + 'allow-downloads': true, + 'allow-modals': true, + 'allow-orientation-lock': false, + 'allow-pointer-lock': true, + 'allow-popups': true, + 'allow-popups-to-escape-sandbox': true, + 'allow-presentation': true, + 'allow-storage-access-by-user-activation': true, + 'allow-top-navigation': true, + 'allow-top-navigation-by-user-activation': true, + 'allow-scripts': true, + 'allow-same-origin': true, + 'allow-forms': true, + })} />
{ ) } - override toSvg(shape: PreviewShape, _ctx: SvgExportContext): SVGElement | Promise { + override toSvg(shape: PreviewShape, _ctx: SvgExportContext): Promise { const g = document.createElementNS('http://www.w3.org/2000/svg', 'g') // while screenshot is the same as the old one, keep waiting for a new one return new Promise((resolve, _) => { - if (window === undefined) return resolve(g) + if (window === undefined) return resolve() const windowListener = (event: MessageEvent) => { if (event.data.screenshot && event.data?.shapeid === shape.id) { - const image = document.createElementNS('http://www.w3.org/2000/svg', 'image') - image.setAttributeNS('http://www.w3.org/1999/xlink', 'href', event.data.screenshot) - image.setAttribute('width', shape.props.w.toString()) - image.setAttribute('height', shape.props.h.toString()) - g.appendChild(image) window.removeEventListener('message', windowListener) clearTimeout(timeOut) - resolve(g) + resolve( + + + + ) } } const timeOut = setTimeout(() => { - resolve(g) + resolve() window.removeEventListener('message', windowListener) }, 2000) window.addEventListener('message', windowListener) @@ -238,7 +258,7 @@ const ROTATING_BOX_SHADOWS = [ }, ] -function getRotatedBoxShadow(rotation: number) { +export function getRotatedBoxShadow(rotation: number) { const cssStrings = ROTATING_BOX_SHADOWS.map((shadow) => { const { offsetX, offsetY, blur, spread, color } = shadow const vec = new Vec(offsetX, offsetY) diff --git a/app/components/ExportButton.tsx b/app/components/ExportButton.tsx index 914adedb..e664fcfc 100644 --- a/app/components/ExportButton.tsx +++ b/app/components/ExportButton.tsx @@ -1,17 +1,66 @@ +import { useMakeHappen } from '../hooks/useMakeHappen' import { useMakeReal } from '../hooks/useMakeReal' +import { useMakeRealAnthropic } from '../hooks/useMakeRealAnthropic' export function ExportButton() { const makeReal = useMakeReal() + const makeRealAnthropicHaiku = useMakeRealAnthropic('claude-3-haiku-20240307') + const makeRealAntrhopicSonnet = useMakeRealAnthropic('claude-3-sonnet-20240229') + const makeRealAnthropicOpus = useMakeRealAnthropic('claude-3-opus-20240229') + const makeHappen = useMakeHappen() return ( - + + {/* + + */} + +
) } diff --git a/app/hooks/useMakeHappen.ts b/app/hooks/useMakeHappen.ts new file mode 100644 index 00000000..da3a7936 --- /dev/null +++ b/app/hooks/useMakeHappen.ts @@ -0,0 +1,42 @@ +import { track } from '@vercel/analytics/react' +import { useCallback } from 'react' +import { useEditor, useToasts } from 'tldraw' +import { makeHappen } from '../lib/makeHappen' + +export function useMakeHappen() { + const editor = useEditor() + const toast = useToasts() + + return useCallback(async () => { + const input = document.getElementById('openai_key_risky_but_cool') as HTMLInputElement + const apiKey = input?.value ?? null + + track('make_real', { timestamp: Date.now() }) + + try { + await makeHappen(editor, apiKey) + } catch (e: any) { + track('no_luck', { timestamp: Date.now() }) + + console.error(e) + + toast.addToast({ + title: 'Something went wrong', + description: `${e.message.slice(0, 200)}`, + actions: [ + { + type: 'primary', + label: 'Read the guide', + onClick: () => { + // open a new tab with the url... + window.open( + 'https://tldraw.notion.site/Make-Real-FAQs-93be8b5273d14f7386e14eb142575e6e', + '_blank' + ) + }, + }, + ], + }) + } + }, [editor, toast]) +} diff --git a/app/hooks/useMakeRealAnthropic.ts b/app/hooks/useMakeRealAnthropic.ts new file mode 100644 index 00000000..0dbabe9d --- /dev/null +++ b/app/hooks/useMakeRealAnthropic.ts @@ -0,0 +1,42 @@ +import { track } from '@vercel/analytics/react' +import { useCallback } from 'react' +import { useEditor, useToasts } from 'tldraw' +import { makeRealAnthropic } from '../lib/makeRealAnthropic' + +export function useMakeRealAnthropic(model: string) { + const editor = useEditor() + const toast = useToasts() + + return useCallback(async () => { + const input = document.getElementById('openai_key_risky_but_cool') as HTMLInputElement + const apiKey = input?.value ?? null + + track('make_real', { timestamp: Date.now() }) + + try { + await makeRealAnthropic(editor, apiKey, model) + } catch (e: any) { + track('no_luck', { timestamp: Date.now() }) + + console.error(e) + + toast.addToast({ + title: 'Something went wrong', + description: `${e.message.slice(0, 200)}`, + actions: [ + { + type: 'primary', + label: 'Read the guide', + onClick: () => { + // open a new tab with the url... + window.open( + 'https://tldraw.notion.site/Make-Real-FAQs-93be8b5273d14f7386e14eb142575e6e', + '_blank' + ) + }, + }, + ], + }) + } + }, [editor, toast, model]) +} diff --git a/app/lib/getHtmlFromAnthropic.ts b/app/lib/getHtmlFromAnthropic.ts new file mode 100644 index 00000000..6bb840bb --- /dev/null +++ b/app/lib/getHtmlFromAnthropic.ts @@ -0,0 +1,204 @@ +import { PreviewShape } from '../PreviewShape/PreviewShape' +import { OPENAI_USER_PROMPT } from '../prompt' + +export async function getHtmlFromAnthropic({ + image, + apiKey, + text, + grid, + theme = 'light', + previousPreviews, + model, +}: { + image: string + apiKey: string + text: string + theme?: string + grid?: { + color: string + size: number + labels: boolean + } + previousPreviews?: PreviewShape[] + model: string +}) { + if (!apiKey) throw Error('You need to provide an API key (sorry)') + + const userContent = [] as any + + // Add the prompt into + userContent.push({ + type: 'text', + text: OPENAI_USER_PROMPT, + // previousPreviews.length > 0 ? OPENAI_USER_PROMPT_WITH_PREVIOUS_DESIGN : OPENAI_USER_PROMPT, + }) + + // console.log(image) + + // Add the image + userContent.push({ + type: 'image', + source: { + type: 'base64', + media_type: 'image/png', + data: image.slice('data:image/png;base64,'.length), + }, + }) + + // Add the strings of text + if (text) { + userContent.push({ + type: 'text', + text: `Here's a list of text that we found in the design:\n${text}`, + }) + } + + if (grid) { + userContent.push({ + type: 'text', + text: `The designs have a ${grid.color} grid overlaid on top. Each cell of the grid is ${grid.size}x${grid.size}px.`, + }) + } + + // Add the previous previews as HTML + // for (let i = 0; i < previousPreviews.length; i++) { + // const preview = previousPreviews[i] + // userContent.push( + // { + // type: 'text', + // text: `The designs also included one of your previous result. Here's the image that you used as its source:`, + // }, + // { + // // type: 'image_url', + // // image_url: { + // // url: preview.props.source, + // // detail: 'high', + // // }, + // type: 'image', + // source: { + // type: 'base64', + // media_type: 'image/jpeg', + // data: preview.props.source, + // }, + // }, + // { + // type: 'text', + // text: `And here's the HTML you came up with for it: ${preview.props.html}`, + // } + // ) + // } + + // Prompt the theme + userContent.push({ + type: 'text', + text: `Please make your result use the ${theme} theme.`, + }) + + // console.log(userContent) + + const response = await fetch('/api/anthropic', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userContent, + model, + }), + }) + + return await response.json() + + // console.log(await response.json()) + + // console.log(userContent) + + // const params: Anthropic.MessageCreateParams = { + // max_tokens: 1000, + // messages: [{ role: 'user', content: 'Hello, Claude' }], + // model: 'claude-3-opus-20240229', + // } + // const message: Anthropic.Message = await anthropic.messages.create(params) + + // const resp = await fetch('https://api.anthropic.com/v1/messages', { + // method: 'POST', + // headers: { + // 'content-type': 'application/json', + // 'x-api-key': apiKey, + // }, + // body: JSON.stringify(params), + // // mode: 'no-cors', + // }) + + // console.log(resp) + + // const body: GPT4VCompletionRequest = { + // model: 'gpt-4-turbo', + // max_tokens: 4096, + // temperature: 0, + // messages, + // seed: 42, + // n: 1, + // } + + // let json = null + + // try { + // const resp = await fetch('https://api.openai.com/v1/chat/completions', { + // method: 'POST', + // headers: { + // 'Content-Type': 'application/json', + // Authorization: `Bearer ${apiKey}`, + // }, + // body: JSON.stringify(body), + // }) + // json = await resp.json() + // } catch (e) { + // throw Error(`Could not contact OpenAI: ${e.message}`) + // } + + // return json +} + +type MessageContent = + | string + | ( + | string + | { + type: 'image_url' + image_url: + | string + | { + url: string + detail: 'low' | 'high' | 'auto' + } + } + | { + type: 'text' + text: string + } + )[] + +export type GPT4VCompletionRequest = { + model: 'gpt-4-turbo' + messages: { + role: 'system' | 'user' | 'assistant' | 'function' + content: MessageContent + name?: string | undefined + }[] + functions?: any[] | undefined + function_call?: any | undefined + stream?: boolean | undefined + temperature?: number | undefined + top_p?: number | undefined + max_tokens?: number | undefined + n?: number | undefined + best_of?: number | undefined + frequency_penalty?: number | undefined + presence_penalty?: number | undefined + seed?: number | undefined + logit_bias?: + | { + [x: string]: number + } + | undefined + stop?: (string[] | string) | undefined +} diff --git a/app/lib/getHtmlFromOpenAI.ts b/app/lib/getHtmlFromOpenAI.ts index e1e4867f..40287b41 100644 --- a/app/lib/getHtmlFromOpenAI.ts +++ b/app/lib/getHtmlFromOpenAI.ts @@ -9,7 +9,6 @@ export async function getHtmlFromOpenAI({ image, apiKey, text, - grid, theme = 'light', previousPreviews, }: { @@ -17,11 +16,6 @@ export async function getHtmlFromOpenAI({ apiKey: string text: string theme?: string - grid?: { - color: string - size: number - labels: boolean - } previousPreviews?: PreviewShape[] }) { if (!apiKey) throw Error('You need to provide an API key (sorry)') @@ -59,14 +53,7 @@ export async function getHtmlFromOpenAI({ if (text) { userContent.push({ type: 'text', - text: `Here's a list of text that we found in the design:\n${text}`, - }) - } - - if (grid) { - userContent.push({ - type: 'text', - text: `The designs have a ${grid.color} grid overlaid on top. Each cell of the grid is ${grid.size}x${grid.size}px.`, + text: `Here's a list of all the text that we found in the design. Use it as a reference if anything is hard to read in the screenshot(s):\n${text}`, }) } @@ -99,7 +86,7 @@ export async function getHtmlFromOpenAI({ }) const body: GPT4VCompletionRequest = { - model: 'gpt-4-vision-preview', + model: 'gpt-4o', max_tokens: 4096, temperature: 0, messages, @@ -146,7 +133,7 @@ type MessageContent = )[] export type GPT4VCompletionRequest = { - model: 'gpt-4-vision-preview' + model: string messages: { role: 'system' | 'user' | 'assistant' | 'function' content: MessageContent diff --git a/app/lib/getPerfectDashProps.ts b/app/lib/getPerfectDashProps.ts new file mode 100644 index 00000000..ecac502f --- /dev/null +++ b/app/lib/getPerfectDashProps.ts @@ -0,0 +1,96 @@ +import { TLDefaultDashStyle } from '@tldraw/editor' + +export function getPerfectDashProps( + totalLength: number, + strokeWidth: number, + opts = {} as Partial<{ + style: TLDefaultDashStyle + snap: number + end: 'skip' | 'outset' | 'none' + start: 'skip' | 'outset' | 'none' + lengthRatio: number + closed: boolean + }> +): { + strokeDasharray: string + strokeDashoffset: string +} { + const { + closed = false, + snap = 1, + start = 'outset', + end = 'outset', + lengthRatio = 2, + style = 'dashed', + } = opts + + let dashLength = 0 + let dashCount = 0 + let ratio = 1 + let gapLength = 0 + let strokeDashoffset = 0 + + switch (style) { + case 'dashed': { + ratio = 1 + dashLength = Math.min(strokeWidth * lengthRatio, totalLength / 4) + break + } + case 'dotted': { + ratio = 100 + dashLength = strokeWidth / ratio + break + } + default: { + return { + strokeDasharray: 'none', + strokeDashoffset: 'none', + } + } + } + + if (!closed) { + if (start === 'outset') { + totalLength += dashLength / 2 + strokeDashoffset += dashLength / 2 + } else if (start === 'skip') { + totalLength -= dashLength + strokeDashoffset -= dashLength + } + + if (end === 'outset') { + totalLength += dashLength / 2 + } else if (end === 'skip') { + totalLength -= dashLength + } + } + + dashCount = Math.floor(totalLength / dashLength / (2 * ratio)) + dashCount -= dashCount % snap + + if (dashCount < 3 && style === 'dashed') { + if (totalLength / strokeWidth < 5) { + dashLength = totalLength + dashCount = 1 + gapLength = 0 + } else { + dashLength = totalLength * 0.333 + gapLength = totalLength * 0.333 + } + } else { + dashCount = Math.max(dashCount, 3) + dashLength = totalLength / dashCount / (2 * ratio) + + if (closed) { + strokeDashoffset = dashLength / 2 + gapLength = (totalLength - dashCount * dashLength) / dashCount + } else { + gapLength = (totalLength - dashCount * dashLength) / Math.max(1, dashCount - 1) + } + } + + return { + strokeDasharray: [dashLength, gapLength].join(' '), + strokeDashoffset: strokeDashoffset.toString(), + } +} diff --git a/app/lib/getTextFromOpenAI.ts b/app/lib/getTextFromOpenAI.ts new file mode 100644 index 00000000..ef6dd901 --- /dev/null +++ b/app/lib/getTextFromOpenAI.ts @@ -0,0 +1,155 @@ +import { PreviewShape } from '../PreviewShape/PreviewShape' +import { OPEN_AI_HAPPEN_SYSTEM_PROMPT } from '../prompt' + +export async function getTextFromOpenAI({ + image, + apiKey, + text, + theme = 'light', + previousPreviews, +}: { + image: string + apiKey: string + text: string + theme?: string + previousPreviews?: PreviewShape[] +}) { + if (!apiKey) throw Error('You need to provide an API key (sorry)') + + const messages: GPT4VCompletionRequest['messages'] = [ + { + role: 'system', + content: OPEN_AI_HAPPEN_SYSTEM_PROMPT, + }, + { + role: 'user', + content: [], + }, + ] + + const userContent = messages[1].content as Exclude + + // Add the prompt into + // userContent.push({ + // type: 'text', + // text: + // previousPreviews.length > 0 ? OPENAI_USER_PROMPT_WITH_PREVIOUS_DESIGN : OPENAI_USER_PROMPT, + // }) + + // Add the image + userContent.push({ + type: 'image_url', + image_url: { + url: image, + detail: 'high', + }, + }) + + // Add the strings of text + if (text) { + userContent.push({ + type: 'text', + text: `Here's a list of all the text that we found in the screenshot. Use it as a reference if anything is hard to read in the screenshot:\n${text}`, + }) + } + + // Add the previous previews as HTML + // for (let i = 0; i < previousPreviews.length; i++) { + // const preview = previousPreviews[i] + // userContent.push( + // { + // type: 'text', + // text: `The designs also included one of your previous result. Here's the image that you used as its source:`, + // }, + // { + // type: 'image_url', + // image_url: { + // url: preview.props.source, + // detail: 'high', + // }, + // }, + // { + // type: 'text', + // text: `And here's the HTML you came up with for it: ${preview.props.html}`, + // } + // ) + // } + + // Prompt the theme + // userContent.push({ + // type: 'text', + // text: `Please make your result use the ${theme} theme.`, + // }) + + const body: GPT4VCompletionRequest = { + model: 'gpt-4-turbo', + max_tokens: 4096, + temperature: 0, + messages, + seed: 42, + n: 1, + } + + let json = null + + try { + const resp = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(body), + }) + json = await resp.json() + } catch (e) { + throw Error(`Could not contact OpenAI: ${e.message}`) + } + + return json +} + +type MessageContent = + | string + | ( + | string + | { + type: 'image_url' + image_url: + | string + | { + url: string + detail: 'low' | 'high' | 'auto' + } + } + | { + type: 'text' + text: string + } + )[] + +export type GPT4VCompletionRequest = { + model: 'gpt-4-turbo' + messages: { + role: 'system' | 'user' | 'assistant' | 'function' + content: MessageContent + name?: string | undefined + }[] + functions?: any[] | undefined + function_call?: any | undefined + stream?: boolean | undefined + temperature?: number | undefined + top_p?: number | undefined + max_tokens?: number | undefined + n?: number | undefined + best_of?: number | undefined + frequency_penalty?: number | undefined + presence_penalty?: number | undefined + seed?: number | undefined + logit_bias?: + | { + [x: string]: number + } + | undefined + stop?: (string[] | string) | undefined +} diff --git a/app/lib/iframe.tsx b/app/lib/iframe.tsx new file mode 100644 index 00000000..9dfecd5d --- /dev/null +++ b/app/lib/iframe.tsx @@ -0,0 +1,119 @@ +import { + BaseBoxShapeTool, + Geometry2d, + Rectangle2d, + ShapeProps, + ShapeUtil, + T, + TLBaseShape, + TLEmbedShapePermissions, + TLOnResizeHandler, + resizeBox, + toDomPrecision, + useIsEditing, + useValue, +} from 'tldraw' +import { getRotatedBoxShadow } from '../PreviewShape/PreviewShape' + +export type IframeShape = TLBaseShape< + 'iframe', + { + w: number + h: number + url: string + } +> + +export const getSandboxPermissions = (permissions: TLEmbedShapePermissions) => { + return Object.entries(permissions) + .filter(([_perm, isEnabled]) => isEnabled) + .map(([perm]) => perm) + .join(' ') +} + +export class IframeShapeUtil extends ShapeUtil { + static override type = 'iframe' as const + static override props: ShapeProps = { + w: T.number, + h: T.number, + url: T.string, + } + + getDefaultProps(): IframeShape['props'] { + return { + w: 720, + h: 480, + url: 'localhost:3000', + } + } + + getGeometry(shape: IframeShape): Geometry2d { + return new Rectangle2d({ + width: shape.props.w, + height: shape.props.h, + isFilled: true, + }) + } + override canEdit = () => true + + override onResize: TLOnResizeHandler = (shape, info) => { + return resizeBox(shape, info) + } + + component(shape: IframeShape) { + // eslint-disable-next-line react-hooks/rules-of-hooks + const boxShadow = useValue( + 'box shadow', + () => { + const rotation = this.editor.getShapePageTransform(shape)!.rotation() + return getRotatedBoxShadow(rotation) + }, + [this.editor] + ) + + // eslint-disable-next-line react-hooks/rules-of-hooks + const isEditing = useIsEditing(shape.id) + return ( +