mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 08:13:34 +00:00
feat(grid): render HTML in value conversion preview
This commit is contained in:
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { CELL_TRANSFORM_KINDS, CELL_TRANSFORM_MAX_INPUT, CELL_TRANSFORM_MAX_OUTPUT, CELL_TRANSFORM_MAX_RADIX_DIGITS, transformCellValue, type CellTransformKind, type CellTransformResult } from "@/lib/dataGrid/cellValueTransform";
|
||||
import { DataGridDateTimePatterns, getSupportedTimeZoneOptions } from "@/lib/dataGrid/columnFormatter";
|
||||
import { buildSafeHtmlPreview } from "@/lib/common/safeHtmlPreview";
|
||||
|
||||
const props = defineProps<{
|
||||
source: string | null;
|
||||
@@ -81,6 +82,8 @@ function convert() {
|
||||
if (blockedReason.value || props.source === null) return;
|
||||
result.value = transformCellValue(props.source, { kind: kind.value, unit: unit.value, timezone: timezone.value, pattern: pattern.value, fromBase: fromBase.value, toBase: toBase.value });
|
||||
}
|
||||
const htmlPreviewDocument = computed(() => (kind.value === "html" && result.value?.ok ? buildSafeHtmlPreview(result.value.text) : ""));
|
||||
|
||||
async function copyResult() {
|
||||
if (!result.value?.ok) return;
|
||||
try {
|
||||
@@ -155,6 +158,7 @@ async function copyResult() {
|
||||
>
|
||||
<Button variant="outline" size="sm" class="h-6 text-xs" @click="copyResult">{{ t("cellTransform.copy") }}</Button>
|
||||
</div>
|
||||
<iframe v-if="kind === 'html'" :srcdoc="htmlPreviewDocument" sandbox="" :title="t('cellTransform.htmlPreview')" class="h-64 min-h-24 w-full rounded border bg-white" />
|
||||
<textarea :value="result.text" :aria-label="t('cellTransform.result')" readonly class="dbx-data-grid-value-font h-64 min-h-24 w-full resize-y rounded border bg-muted/20 p-3 text-xs" spellcheck="false" />
|
||||
</template>
|
||||
</DialogContent>
|
||||
|
||||
@@ -132,6 +132,7 @@ export default withEnglishFallback({
|
||||
convert: "Çevir",
|
||||
result: "Nəticə",
|
||||
copy: "Nəticəni köçür",
|
||||
htmlPreview: "HTML preview",
|
||||
radixHint: "Yalnız tam ədədlər. 0x, 0o və ya 0b prefiksi olmadan rəqəmlər daxil edin; işarə istifadə oluna bilər.",
|
||||
urlHint: "URL komponentləri UTF-8 ilə kodlanır və ya dekodlaşdırılır. Dekodlaşdırma + işarəsini boşluğa çevirmir.",
|
||||
kinds: {
|
||||
@@ -139,6 +140,7 @@ export default withEnglishFallback({
|
||||
json: "JSON formatla",
|
||||
jsonCompact: "JSON sıxlaşdır",
|
||||
xml: "XML formatla",
|
||||
html: "HTML preview",
|
||||
base64Encode: "Mətn → Base64 (UTF-8)",
|
||||
base64Decode: "Base64 → mətn (UTF-8)",
|
||||
urlEncode: "URL komponentini kodla",
|
||||
|
||||
@@ -94,6 +94,7 @@ export default {
|
||||
convert: "Convert",
|
||||
result: "Result",
|
||||
copy: "Copy result",
|
||||
htmlPreview: "HTML preview",
|
||||
radixHint: "Integers only. Enter digits without a 0x, 0o or 0b prefix; an optional sign is supported.",
|
||||
urlHint: "URL component encoding (UTF-8). Decoding preserves + as a literal plus sign.",
|
||||
kinds: {
|
||||
@@ -101,6 +102,7 @@ export default {
|
||||
json: "Format JSON",
|
||||
jsonCompact: "Compact JSON",
|
||||
xml: "Format XML",
|
||||
html: "HTML preview",
|
||||
base64Encode: "Text → Base64 (UTF-8)",
|
||||
base64Decode: "Base64 → text (UTF-8)",
|
||||
urlEncode: "URL component encode",
|
||||
|
||||
@@ -98,6 +98,7 @@ export default withEnglishFallback({
|
||||
convert: "Convertir",
|
||||
result: "Resultado",
|
||||
copy: "Copiar resultado",
|
||||
htmlPreview: "HTML preview",
|
||||
radixHint: "Solo enteros. Introduce dígitos sin los prefijos 0x, 0o o 0b; se admite un signo opcional.",
|
||||
urlHint: "Codificación de componentes URL en UTF-8. La decodificación conserva + como signo más.",
|
||||
kinds: {
|
||||
@@ -105,6 +106,7 @@ export default withEnglishFallback({
|
||||
json: "Formatear JSON",
|
||||
jsonCompact: "Compactar JSON",
|
||||
xml: "Formatear XML",
|
||||
html: "HTML preview",
|
||||
base64Encode: "Texto → Base64 (UTF-8)",
|
||||
base64Decode: "Base64 → texto (UTF-8)",
|
||||
urlEncode: "Codificar componente URL",
|
||||
|
||||
@@ -97,6 +97,7 @@ export default withEnglishFallback({
|
||||
convert: "Converti",
|
||||
result: "Risultato",
|
||||
copy: "Copia risultato",
|
||||
htmlPreview: "HTML preview",
|
||||
radixHint: "Solo interi. Inserisci cifre senza prefissi 0x, 0o o 0b; è ammesso un segno facoltativo.",
|
||||
urlHint: "Codifica dei componenti URL in UTF-8. La decodifica mantiene + come segno più.",
|
||||
kinds: {
|
||||
@@ -104,6 +105,7 @@ export default withEnglishFallback({
|
||||
json: "Formatta JSON",
|
||||
jsonCompact: "Compatta JSON",
|
||||
xml: "Formatta XML",
|
||||
html: "HTML preview",
|
||||
base64Encode: "Testo → Base64 (UTF-8)",
|
||||
base64Decode: "Base64 → testo (UTF-8)",
|
||||
urlEncode: "Codifica componente URL",
|
||||
|
||||
@@ -98,6 +98,7 @@ export default withEnglishFallback({
|
||||
convert: "変換",
|
||||
result: "変換結果",
|
||||
copy: "結果をコピー",
|
||||
htmlPreview: "HTML preview",
|
||||
radixHint: "整数のみ対応します。0x、0o、0b 接頭辞は不要です。正負の符号を使用できます。",
|
||||
urlHint: "URL コンポーネントを UTF-8 でエンコード・デコードします。デコード時に + は空白に変換されません。",
|
||||
kinds: {
|
||||
@@ -105,6 +106,7 @@ export default withEnglishFallback({
|
||||
json: "JSON 整形",
|
||||
jsonCompact: "JSON 圧縮",
|
||||
xml: "XML 整形",
|
||||
html: "HTML preview",
|
||||
base64Encode: "テキスト → Base64(UTF-8)",
|
||||
base64Decode: "Base64 → テキスト(UTF-8)",
|
||||
urlEncode: "URL コンポーネントのエンコード",
|
||||
|
||||
@@ -97,6 +97,7 @@ export default withEnglishFallback({
|
||||
convert: "변환",
|
||||
result: "변환 결과",
|
||||
copy: "결과 복사",
|
||||
htmlPreview: "HTML preview",
|
||||
radixHint: "정수만 지원합니다. 0x, 0o, 0b 접두사 없이 입력하세요. 부호는 사용할 수 있습니다.",
|
||||
urlHint: "URL 구성 요소를 UTF-8로 인코딩하거나 디코딩합니다. 디코딩 시 +를 공백으로 바꾸지 않습니다.",
|
||||
kinds: {
|
||||
@@ -104,6 +105,7 @@ export default withEnglishFallback({
|
||||
json: "JSON 서식 지정",
|
||||
jsonCompact: "JSON 압축",
|
||||
xml: "XML 서식 지정",
|
||||
html: "HTML preview",
|
||||
base64Encode: "텍스트 → Base64(UTF-8)",
|
||||
base64Decode: "Base64 → 텍스트(UTF-8)",
|
||||
urlEncode: "URL 구성 요소 인코딩",
|
||||
|
||||
@@ -98,6 +98,7 @@ export default withEnglishFallback({
|
||||
convert: "Converter",
|
||||
result: "Resultado",
|
||||
copy: "Copiar resultado",
|
||||
htmlPreview: "HTML preview",
|
||||
radixHint: "Somente inteiros. Insira dígitos sem os prefixos 0x, 0o ou 0b; um sinal opcional é permitido.",
|
||||
urlHint: "Codificação de componentes URL em UTF-8. A decodificação preserva + como sinal de adição.",
|
||||
kinds: {
|
||||
@@ -105,6 +106,7 @@ export default withEnglishFallback({
|
||||
json: "Formatar JSON",
|
||||
jsonCompact: "Compactar JSON",
|
||||
xml: "Formatar XML",
|
||||
html: "HTML preview",
|
||||
base64Encode: "Texto → Base64 (UTF-8)",
|
||||
base64Decode: "Base64 → texto (UTF-8)",
|
||||
urlEncode: "Codificar componente URL",
|
||||
|
||||
@@ -19,6 +19,7 @@ export default withEnglishFallback({
|
||||
convert: "Преобразовать",
|
||||
result: "Результат",
|
||||
copy: "Копировать результат",
|
||||
htmlPreview: "HTML preview",
|
||||
radixHint: "Только целые числа. Вводите цифры без префикса 0x, 0o или 0b; допускается необязательный знак.",
|
||||
urlHint: "Кодирование компонента URL (UTF-8). При декодировании + сохраняется как знак плюс.",
|
||||
kinds: {
|
||||
@@ -26,6 +27,7 @@ export default withEnglishFallback({
|
||||
json: "Форматировать JSON",
|
||||
jsonCompact: "Сжать JSON",
|
||||
xml: "Форматировать XML",
|
||||
html: "HTML preview",
|
||||
base64Encode: "Текст → Base64 (UTF-8)",
|
||||
base64Decode: "Base64 → текст (UTF-8)",
|
||||
urlEncode: "Кодировать компонент URL",
|
||||
|
||||
@@ -134,6 +134,7 @@ export default withEnglishFallback({
|
||||
convert: "Dönüştür",
|
||||
result: "Sonuç",
|
||||
copy: "Sonucu kopyala",
|
||||
htmlPreview: "HTML preview",
|
||||
radixHint: "Yalnızca tam sayılar. 0x, 0o veya 0b öneki olmadan rakam girin; isteğe bağlı işaret kullanılabilir.",
|
||||
urlHint: "URL bileşenleri UTF-8 ile kodlanır veya çözülür. Çözme işlemi + işaretini boşluğa dönüştürmez.",
|
||||
kinds: {
|
||||
@@ -141,6 +142,7 @@ export default withEnglishFallback({
|
||||
json: "JSON biçimlendir",
|
||||
jsonCompact: "JSON sıkıştır",
|
||||
xml: "XML biçimlendir",
|
||||
html: "HTML preview",
|
||||
base64Encode: "Metin → Base64 (UTF-8)",
|
||||
base64Decode: "Base64 → metin (UTF-8)",
|
||||
urlEncode: "URL bileşenini kodla",
|
||||
|
||||
@@ -19,6 +19,7 @@ export default withEnglishFallback({
|
||||
convert: "转换",
|
||||
result: "转换结果",
|
||||
copy: "复制结果",
|
||||
htmlPreview: "HTML 渲染预览",
|
||||
radixHint: "仅支持整数,请勿输入 0x、0o 或 0b 前缀,可使用正负号。",
|
||||
urlHint: "按 URL 组件进行 UTF-8 编解码,解码时保留加号,不将其替换为空格。",
|
||||
kinds: {
|
||||
@@ -26,6 +27,7 @@ export default withEnglishFallback({
|
||||
json: "JSON 格式化",
|
||||
jsonCompact: "JSON 压缩",
|
||||
xml: "XML 格式化",
|
||||
html: "HTML 渲染预览",
|
||||
base64Encode: "文本 → Base64(UTF-8)",
|
||||
base64Decode: "Base64 → 文本(UTF-8)",
|
||||
urlEncode: "URL 组件编码",
|
||||
|
||||
@@ -97,6 +97,7 @@ export default withEnglishFallback({
|
||||
convert: "轉換",
|
||||
result: "轉換結果",
|
||||
copy: "複製結果",
|
||||
htmlPreview: "HTML 渲染預覽",
|
||||
radixHint: "僅支援整數,請勿輸入 0x、0o 或 0b 前綴,可使用正負號。",
|
||||
urlHint: "按 URL 元件進行 UTF-8 編解碼,解碼時保留加號,不將其替換為空格。",
|
||||
kinds: {
|
||||
@@ -104,6 +105,7 @@ export default withEnglishFallback({
|
||||
json: "JSON 格式化",
|
||||
jsonCompact: "JSON 壓縮",
|
||||
xml: "XML 格式化",
|
||||
html: "HTML 渲染預覽",
|
||||
base64Encode: "文字 → Base64(UTF-8)",
|
||||
base64Decode: "Base64 → 文字(UTF-8)",
|
||||
urlEncode: "URL 元件編碼",
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { buildSafeHtmlPreview, SAFE_HTML_PREVIEW_CSP } from "@/lib/common/safeHtmlPreview";
|
||||
|
||||
export { buildSafeHtmlPreview };
|
||||
|
||||
/**
|
||||
* Safe HTML preview shell (V1).
|
||||
*
|
||||
@@ -30,40 +34,13 @@
|
||||
/** Max raw ```html fence body length (characters) that may enter the iframe `srcdoc`. */
|
||||
export const AI_HTML_MAX_CONTENT_CHARS = 512 * 1024;
|
||||
|
||||
export const AI_HTML_PREVIEW_CSP = "default-src 'none'; base-uri 'none'; form-action 'none'; img-src data: blob:; style-src 'unsafe-inline'; font-src data:";
|
||||
export const AI_HTML_PREVIEW_CSP = SAFE_HTML_PREVIEW_CSP;
|
||||
|
||||
/** True when the raw fence body may be previewed / saved. Oversize stays a plain code segment. */
|
||||
export function isAiHtmlPreviewEligible(content: string): boolean {
|
||||
return content.length > 0 && content.length <= AI_HTML_MAX_CONTENT_CHARS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches any `<meta>` carrying `http-equiv=refresh`: case-insensitive on the
|
||||
* tag name and the `http-equiv` key/value, tolerant of unquoted, single- or
|
||||
* double-quoted values in any attribute order with extra attributes present.
|
||||
*/
|
||||
const AI_HTML_META_REFRESH_PATTERN = /<meta\b[^>]*?\bhttp-equiv\s*=\s*(?:"\s*refresh\s*"|'\s*refresh\s*'|refresh\b)[^>]*>/gi;
|
||||
|
||||
/** Inert replacement for a defused refresh meta: a comment navigates nowhere. */
|
||||
const AI_HTML_META_REFRESH_REPLACEMENT = "<!-- meta refresh removed -->";
|
||||
|
||||
/** Strip every `http-equiv=refresh` meta; everything else passes through byte-identical. */
|
||||
function defuseMetaRefresh(content: string): string {
|
||||
return content.replace(AI_HTML_META_REFRESH_PATTERN, AI_HTML_META_REFRESH_REPLACEMENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap raw AI HTML in the safe standalone document. The same bytes back BOTH
|
||||
* the iframe preview and the "Save Safe HTML" payload, so what is previewed is
|
||||
* exactly what gets saved. If the AI content is itself a complete document,
|
||||
* the browser's error recovery nests it inside the body while the outer CSP
|
||||
* meta still applies document-wide. The only rewrite is the refresh meta
|
||||
* defused above — navigation sits outside the CSP fetch model.
|
||||
*/
|
||||
export function buildSafeHtmlPreview(content: string): string {
|
||||
return ["<!doctype html>", "<html>", "<head>", '<meta charset="utf-8">', `<meta http-equiv="Content-Security-Policy" content="${AI_HTML_PREVIEW_CSP}">`, "</head>", "<body>", defuseMetaRefresh(content), "</body>", "</html>"].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* "Copy source" puts the raw AI HTML on the clipboard where it may later be
|
||||
* opened outside DBX. The first copy of a session requires an explicit risk
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/** CSP used by read-only HTML previews rendered inside sandboxed iframes. */
|
||||
export const SAFE_HTML_PREVIEW_CSP = "default-src 'none'; base-uri 'none'; form-action 'none'; img-src data: blob:; style-src 'unsafe-inline'; font-src data:";
|
||||
|
||||
const META_REFRESH_PATTERN = /<meta\b[^>]*?\bhttp-equiv\s*=\s*(?:"\s*refresh\s*"|'\s*refresh\s*'|refresh\b)[^>]*>/gi;
|
||||
|
||||
/** Wrap untrusted HTML in a CSP-protected document and remove meta refresh navigation. */
|
||||
export function buildSafeHtmlPreview(content: string): string {
|
||||
const safeContent = content.replace(META_REFRESH_PATTERN, "<!-- meta refresh removed -->");
|
||||
return ["<!doctype html>", "<html>", "<head>", '<meta charset="utf-8">', `<meta http-equiv="Content-Security-Policy" content="${SAFE_HTML_PREVIEW_CSP}">`, "</head>", "<body>", safeContent, "</body>", "</html>"].join("\n");
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { applyColumnFormatter, DataGridDateTimePatterns } from "@/lib/dataGrid/c
|
||||
export const CELL_TRANSFORM_MAX_INPUT = 50_000;
|
||||
export const CELL_TRANSFORM_MAX_OUTPUT = 200_000;
|
||||
export const CELL_TRANSFORM_MAX_RADIX_DIGITS = 4_096;
|
||||
export const CELL_TRANSFORM_KINDS = ["timestamp", "json", "jsonCompact", "xml", "base64Encode", "base64Decode", "urlEncode", "urlDecode", "radix"] as const;
|
||||
export const CELL_TRANSFORM_KINDS = ["timestamp", "json", "jsonCompact", "xml", "html", "base64Encode", "base64Decode", "urlEncode", "urlDecode", "radix"] as const;
|
||||
export type CellTransformKind = (typeof CELL_TRANSFORM_KINDS)[number];
|
||||
export type CellTransformError = "tooLarge" | "invalid" | "invalidTimestamp" | "ambiguousUnit" | "invalidInteger" | "invalidBase64";
|
||||
export interface CellTransformOptions {
|
||||
@@ -65,6 +65,9 @@ export function transformCellValue(source: string, options: CellTransformOptions
|
||||
case "xml":
|
||||
text = formatXmlSource(source);
|
||||
break;
|
||||
case "html":
|
||||
text = source;
|
||||
break;
|
||||
case "urlEncode":
|
||||
text = encodeURIComponent(source);
|
||||
break;
|
||||
|
||||
@@ -85,6 +85,11 @@ test("xml rejects malformed markup", () => {
|
||||
assert.deepEqual(transformCellValue("<a></b>", { kind: "xml" }), { ok: false, error: "invalid" });
|
||||
});
|
||||
|
||||
test("html preview preserves source markup for rendering", () => {
|
||||
const source = '<article><h1>Hello</h1><p class="lead">World</p></article>';
|
||||
assert.deepEqual(transformCellValue(source, { kind: "html" }), { ok: true, text: source });
|
||||
});
|
||||
|
||||
test("base64 round-trips CJK text and emoji", () => {
|
||||
const encoded = transformCellValue("你好😀", { kind: "base64Encode" });
|
||||
assert.deepEqual(encoded, { ok: true, text: "5L2g5aW98J+YgA==" });
|
||||
|
||||
Reference in New Issue
Block a user