{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "browser",
  "title": "Browser",
  "description": "A web page inside your drawing. Click to place it or drag to size it, type an address in the bar above it, and switch between phone, tablet, and desktop widths with the buttons on the right.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "btn0s/tldraw-mods/tool-chrome"
  ],
  "files": [
    {
      "path": "workspace/src/mods/browser.tsx",
      "content": "import { useState, type FormEvent, type SyntheticEvent } from 'react'\nimport { Monitor, PanelsTopLeft, RotateCw, Smartphone, Tablet } from 'lucide-react'\nimport type { ModConfig, ModTool } from '@/mod'\nimport { HTMLContainer, Rectangle2d, ShapeUtil, T, resizeBox, type Editor, type RecordProps, type TLBaseShape, type TLResizeInfo } from 'tldraw'\nimport { cn } from '@/lib/utils'\nimport { EditingBoxShapeTool, ToolChrome, ToolChromeField, ToolChromeGroup, useShapeEditing } from '@/tool-chrome'\n\n// The preset fold is state- and index-driven (delays per key via --i) and lives here as CSS; everything else is Tailwind.\n// Symmetric margins instead of gap: folded keys take their spacing with them, so the lone active key stays centered.\nconst foldCss = `\n.browser-presets { --fold-out:cubic-bezier(.22,1,.36,1); --fold-in:cubic-bezier(.4,0,.2,1); }\n.browser-presets .ui-key { overflow:hidden; margin:0 2px; scale:1; transition:width 240ms var(--fold-in) 140ms,margin 240ms var(--fold-in) 140ms,opacity 160ms ease 140ms,scale 240ms var(--fold-in) 140ms,transform 120ms ease,background 90ms ease,box-shadow 90ms ease; }\n.browser-presets[data-collapsed=true]:not(:hover,:focus-within) .ui-key:not([aria-pressed=true]) { width:0; margin:0; opacity:0; scale:.6; pointer-events:none; }\n.browser-presets[data-collapsed=true]:is(:hover,:focus-within) .ui-key { transition:width 360ms var(--fold-out) calc(var(--i) * 30ms),margin 360ms var(--fold-out) calc(var(--i) * 30ms),opacity 220ms ease calc(var(--i) * 30ms),scale 360ms var(--fold-out) calc(var(--i) * 30ms),transform 120ms ease,background 90ms ease,box-shadow 90ms ease; }\n@media (hover:none),(pointer:coarse) { .browser-presets[data-collapsed=true] .ui-key:not([aria-pressed=true]) { width:32px; margin:0 2px; opacity:1; scale:1; pointer-events:auto; } }\n@media (prefers-reduced-motion:reduce) { .browser-presets .ui-key { transition:none !important; } }\n`\n\nexport interface BrowserProps { w: number; h: number; url: string }\ndeclare module '@tldraw/tlschema' { interface TLGlobalShapePropsMap { browser: BrowserProps } }\nexport type BrowserShape = TLBaseShape<'browser', BrowserProps>\n\n// Accept web addresses, never executable or local-file schemes.\nexport function normalizeUrl(value: string) {\n\tconst input = value.trim()\n\tif (!input) throw new Error('Enter a web address.')\n\tconst hasScheme = /^[a-z][a-z\\d+.-]*:/i.test(input)\n\tconst isLocal = /^(localhost|127\\.0\\.0\\.1|\\[::1\\])(:\\d+)?([/?#]|$)/i.test(input)\n\tconst hasHostPort = /^[\\w.-]+:\\d+([/?#]|$)/.test(input)\n\tconst url = new URL(isLocal ? `http://${input}` : hasScheme && !hasHostPort ? input : `https://${input}`)\n\tif (!['http:', 'https:'].includes(url.protocol) || !url.hostname) {\n\t\tthrow new Error('Use an http:// or https:// address.')\n\t}\n\tif (url.username || url.password) throw new Error('Use an address without embedded credentials.')\n\treturn url.href\n}\n\nconst stop = (event: SyntheticEvent) => event.stopPropagation()\nconst viewportPresets = [\n\t{ name: 'Phone', w: 390, h: 844, Icon: Smartphone },\n\t{ name: 'Tablet', w: 768, h: 1024, Icon: Tablet },\n\t{ name: 'Desktop', w: 1440, h: 900, Icon: Monitor },\n]\n\nfunction BrowserShapeView({ shape, editor }: { shape: BrowserShape; editor: Editor }) {\n\tconst { editing, readonly, dark, finish } = useShapeEditing(editor, shape)\n\tconst [reload, setReload] = useState(0)\n\t// The address bar holds a draft until Enter; a change to the saved URL discards it.\n\tconst [draft, setDraft] = useState({ url: shape.props.url, value: shape.props.url })\n\tconst address = draft.url === shape.props.url ? draft.value : shape.props.url\n\tlet url = ''\n\ttry { if (shape.props.url) url = normalizeUrl(shape.props.url) } catch { /* Invalid imported URLs stay inert. */ }\n\n\tfunction navigate(event: FormEvent<HTMLFormElement>) {\n\t\tevent.preventDefault()\n\t\tif (readonly) return\n\t\tconst input = event.currentTarget.elements.namedItem('url') as HTMLInputElement\n\t\ttry {\n\t\t\tconst next = normalizeUrl(input.value)\n\t\t\teditor.markHistoryStoppingPoint('Navigate browser')\n\t\t\teditor.updateShape({ id: shape.id, type: 'browser', props: { url: next } })\n\t\t\tinput.setCustomValidity('')\n\t\t\tif (next === url) setReload((n) => n + 1)\n\t\t} catch (err) {\n\t\t\tinput.setCustomValidity(err instanceof TypeError ? 'Enter a valid web address.' : (err as Error).message)\n\t\t\tinput.reportValidity()\n\t\t}\n\t}\n\n\tconst collapsed = viewportPresets.some(preset => shape.props.w === preset.w && shape.props.h === preset.h)\n\treturn (\n\t\t<HTMLContainer className=\"overflow-visible rounded-none bg-card text-foreground shadow-[0_0_0_1px_#00000014] antialiased\" style={{ width: shape.props.w, height: shape.props.h, colorScheme: dark ? 'dark' : 'light' }}>\n\t\t\t<style>{foldCss}</style>\n\t\t\t{url ? (\n\t\t\t\t<iframe\n\t\t\t\t\tkey={`${url}:${reload}`} src={url} title={`Browser: ${url}`} className={cn('browser-frame block size-full border-0 bg-card', editing ? 'pointer-events-auto' : 'pointer-events-none')}\n\t\t\t\t\tsandbox=\"allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads allow-modals allow-presentation\"\n\t\t\t\t\treferrerPolicy=\"strict-origin-when-cross-origin\" allow=\"fullscreen\"\n\t\t\t\t\ttabIndex={editing ? 0 : -1} onPointerDown={stop}\n\t\t\t\t/>\n\t\t\t) : null}\n\t\t\t{!editing && url ? <div className=\"absolute inset-0\" title=\"Double-click to browse. Click the canvas to finish.\" /> : null}\n\t\t\t<ToolChrome top={[\n\t\t\t\t<ToolChromeGroup key=\"address\" as=\"form\" className=\"min-w-0 max-w-[min(360px,100%)] flex-[0_1_auto] pl-2.5\" onSubmit={navigate}>\n\t\t\t\t\t{/* A page that is already loaded keeps editing for the iframe; only an empty browser wants the address bar. */}\n\t\t\t\t\t<ToolChromeField\n\t\t\t\t\t\tname=\"url\" value={address} placeholder=\"Enter URL…\" aria-label=\"Browser address\" disabled={readonly}\n\t\t\t\t\t\tfocus={editing && !url && !readonly} onEscape={finish}\n\t\t\t\t\t\tonChange={(event) => { event.currentTarget.setCustomValidity(''); setDraft({ url: shape.props.url, value: event.currentTarget.value }) }}\n\t\t\t\t\t/>\n\t\t\t\t\t<button type=\"button\" className=\"ui-icon-button\" title=\"Reload page\" aria-label=\"Reload page\" disabled={!url} onClick={() => setReload((n) => n + 1)}>\n\t\t\t\t\t\t<RotateCw size={18} aria-hidden />\n\t\t\t\t\t</button>\n\t\t\t\t</ToolChromeGroup>,\n\t\t\t\t// With a preset active the group folds down to that one button and unfolds on hover or focus.\n\t\t\t\t<ToolChromeGroup key=\"presets\" className=\"browser-presets shrink-0 gap-0\" role=\"group\" aria-label=\"Viewport presets\" data-collapsed={collapsed}>\n\t\t\t\t\t{viewportPresets.map(({ name, w, h, Icon }, index) => (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tkey={name} type=\"button\" className=\"ui-key ui-icon-button\" style={{ '--i': index }}\n\t\t\t\t\t\t\ttitle={`${name} · ${w} × ${h}`} aria-label={`${name} viewport`}\n\t\t\t\t\t\t\taria-pressed={shape.props.w === w && shape.props.h === h} disabled={readonly}\n\t\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\t\teditor.markHistoryStoppingPoint('Change browser viewport')\n\t\t\t\t\t\t\t\teditor.updateShape({ id: shape.id, type: 'browser', props: { w, h } })\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Icon size={18} aria-hidden />\n\t\t\t\t\t\t</button>\n\t\t\t\t\t))}\n\t\t\t\t</ToolChromeGroup>,\n\t\t\t]} />\n\t\t</HTMLContainer>\n\t)\n}\n\nexport class BrowserShapeUtil extends ShapeUtil<BrowserShape> {\n\tstatic override type = 'browser' as const\n\tstatic override props: RecordProps<BrowserShape> = { w: T.number, h: T.number, url: T.string }\n\tgetDefaultProps(): BrowserShape['props'] { return { w: 900, h: 640, url: '' } }\n\toverride canEdit() { return true }\n\toverride canResize() { return true }\n\toverride hideRotateHandle() { return true }\n\toverride getAriaDescriptor(shape: BrowserShape) { return `Browser: ${shape.props.url || 'empty'}` }\n\tgetGeometry(shape: BrowserShape) { return new Rectangle2d({ width: shape.props.w, height: shape.props.h, isFilled: true }) }\n\tcomponent(shape: BrowserShape) { return <BrowserShapeView shape={shape} editor={this.editor} /> }\n\toverride getIndicatorPath(shape: BrowserShape) {\n\t\tconst path = new Path2D()\n\t\tpath.rect(0, 0, shape.props.w, shape.props.h)\n\t\treturn path\n\t}\n\toverride onResize(shape: BrowserShape, info: TLResizeInfo<BrowserShape>) { return resizeBox(shape, info, { minWidth: 320, minHeight: 200 }) }\n}\n\nexport class BrowserShapeTool extends EditingBoxShapeTool {\n\tstatic override id = 'browser'\n\tstatic override initial = 'idle'\n\toverride shapeType = 'browser' as const\n}\n\nexport const tool: ModTool = { id: 'browser', label: 'Browser', icon: <PanelsTopLeft size={24} aria-hidden /> }\nexport default (({ config }) => {\n\tconfig.shapeUtils.push(BrowserShapeUtil)\n\tconfig.tools.push(BrowserShapeTool)\n}) satisfies ModConfig\n",
      "type": "registry:component",
      "target": "~/src/mods/browser.tsx"
    }
  ],
  "docs": "Added to src/mods/. If the drawing did not reload on its own, run: npm run apply. Some sites refuse to be embedded and will show blank.",
  "categories": [
    "shape"
  ],
  "type": "registry:component"
}