| 1 | import { |
| 2 | COMPOSER_CONTEXT_CLIPBOARD_MIME, |
| 3 | ComposerContextClipboardFragment, |
| 4 | } from "@t3tools/contracts"; |
| 5 | import * as Option from "effect/Option"; |
| 6 | import * as Schema from "effect/Schema"; |
| 7 | |
| 8 | export { COMPOSER_CONTEXT_CLIPBOARD_MIME }; |
| 9 | |
| 10 | const MAX_FRAGMENT_CHARS = 16_000_000; |
| 11 | const decodeFragment = Schema.decodeUnknownOption(ComposerContextClipboardFragment); |
| 12 | |
| 13 | export function encodeComposerContextFragment( |
| 14 | fragment: ComposerContextClipboardFragment, |
| 15 | ): string | null { |
| 16 | const encoded = JSON.stringify(fragment); |
| 17 | return encoded.length <= MAX_FRAGMENT_CHARS ? encoded : null; |
| 18 | } |
| 19 | |
| 20 | /** HTML is the portable flavor shared by browsers and native system clipboards. */ |
| 21 | /** Room for the wrapper element, its attribute name, and the copied HTML itself. */ |
| 22 | const HTML_WRAPPER_SLACK_CHARS = 4096; |
| 23 | |
| 24 | export function encodeComposerContextClipboardHtml( |
| 25 | text: string, |
| 26 | fragment: string, |
| 27 | html?: string, |
| 28 | ): string { |
| 29 | if (html !== undefined) |
| 30 | return `<div data-t3-context-fragment="${encodeURIComponent(fragment)}">${html}</div>`; |
| 31 | const escaped = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); |
| 32 | return `<pre data-t3-context-fragment="${encodeURIComponent(fragment)}">${escaped}</pre>`; |
| 33 | } |
| 34 | |
| 35 | export function decodeComposerContextClipboardHtml( |
| 36 | html: string | null | undefined, |
| 37 | ): ComposerContextClipboardFragment | null { |
| 38 | // `encodeURIComponent` expands one non-ASCII code unit to up to nine characters, so a |
| 39 | // fragment just under the limit must still survive the round trip through the attribute. |
| 40 | if (!html || html.length > MAX_FRAGMENT_CHARS * 9 + HTML_WRAPPER_SLACK_CHARS) return null; |
| 41 | const encoded = /data-t3-context-fragment=["']([^"']+)["']/.exec(html)?.[1]; |
| 42 | if (!encoded) return null; |
| 43 | try { |
| 44 | return decodeComposerContextFragment(decodeURIComponent(encoded)); |
| 45 | } catch { |
| 46 | return null; |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | /** Clipboard data is untrusted: anything that is not a valid version-1 fragment is ignored. */ |
| 51 | export function decodeComposerContextFragment( |
| 52 | raw: string | null | undefined, |
| 53 | ): ComposerContextClipboardFragment | null { |
| 54 | if (!raw || raw.length > MAX_FRAGMENT_CHARS) return null; |
| 55 | try { |
| 56 | return Option.getOrNull(decodeFragment(JSON.parse(raw))); |
| 57 | } catch { |
| 58 | return null; |
| 59 | } |
| 60 | } |