feat(grid): render HTML in value conversion preview

This commit is contained in:
二丫讲梵
2026-09-30 15:01:36 +08:00
committed by GitHub
parent 71068cef16
commit 0fb2f1fddb
16 changed files with 50 additions and 29 deletions
@@ -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>
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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 コンポーネントのエンコード",
+2
View File
@@ -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 구성 요소 인코딩",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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 组件编码",
+2
View File
@@ -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;
+5
View File
@@ -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==" });