{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "browser",
  "title": "Browser",
  "description": "An embedded web page. Click to place or drag to size; type a URL in the pill above the frame. Phone, tablet, and desktop viewport presets float above the right edge.",
  "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": "Installed to src/mods/. Run npm run apply to load it into the open document. Sites that send X-Frame-Options or CSP frame-ancestors will not embed.",
  "categories": [
    "shape"
  ],
  "type": "registry:component"
}