feat(mongo): add foldable document preview
This commit is contained in:
6 files changed
+83
-226
No files matched your search
@@ -2,7 +2,7 @@
|
||||
import { computed, ref, nextTick, watch, onMounted, onBeforeUnmount } from "vue";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { RefreshCw, Trash2, Plus, Save, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Table2, Braces, X, Search, Wrench, Filter, Columns3Cog, SquareDashed, Minus, Rows3, AlignLeft, AlignRight, EyeOff } from "@lucide/vue";
|
||||
import { RefreshCw, Trash2, Plus, Save, ChevronDown, ChevronLeft, ChevronRight, Table2, Braces, X, Search, Wrench, Filter, Columns3Cog, SquareDashed, Minus, Rows3, AlignLeft, AlignRight, EyeOff } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -82,7 +82,6 @@ import {
|
||||
} from "@/lib/mongo/mongoDocumentValues";
|
||||
import type { GridNewRowMeta } from "@/lib/dataGrid/gridNewRowPlacement";
|
||||
import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
|
||||
import { findDocumentTextMatches, renderDocumentJsonHtml } from "@/lib/document/documentJsonSearch";
|
||||
import { documentDataGridColumnLayoutScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
|
||||
import { documentGridColumnVisibilityScopeKey, migrateDocumentGridColumnVisibilityToLayout } from "@/lib/document/documentGridColumnVisibilityStorage";
|
||||
import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore } from "@/stores/settingsStore";
|
||||
@@ -146,12 +145,7 @@ const sortInputRef = ref<HTMLTextAreaElement>();
|
||||
const dataGridRef = ref<InstanceType<typeof DataGrid>>();
|
||||
const viewOptionsOpen = ref(false);
|
||||
const mongoUpdateTarget = computed(() => (props.databaseType === "mongodb" && mongoCopyDocumentsAvailable.value ? { collection: props.collection, idColumn: "_id" as const } : undefined));
|
||||
const documentViewerRef = ref<HTMLElement>();
|
||||
const documentSearchInputRef = ref<HTMLInputElement>();
|
||||
const documentSearchOpen = ref(false);
|
||||
const documentSearchQuery = ref("");
|
||||
const documentSearchMatchIndex = ref(0);
|
||||
const documentSearchHasNavigated = ref(false);
|
||||
const documentJsonEditorRef = ref<{ openSearch: () => boolean }>();
|
||||
const documentViewerSearchActive = ref(false);
|
||||
|
||||
function openDataGridExtractorConfiguration() {
|
||||
@@ -285,16 +279,6 @@ const selectedDocumentIdLabel = computed(() => {
|
||||
return typeof id === "object" ? stringifyDocumentStoreValue(id, documentStoreProvider.value.kind) : String(id);
|
||||
});
|
||||
const selectedDocumentIdWidth = computed(() => `${Math.min(Math.max(Array.from(selectedDocumentIdLabel.value).length + 2, 5), 52)}ch`);
|
||||
const documentSearchText = computed(() => editJson.value);
|
||||
const documentSearchMatches = computed(() => findDocumentTextMatches(documentSearchText.value, documentSearchQuery.value));
|
||||
const documentSearchActiveIndex = computed(() => {
|
||||
if (documentSearchMatches.value.length === 0) return 0;
|
||||
return Math.min(documentSearchMatchIndex.value, documentSearchMatches.value.length - 1);
|
||||
});
|
||||
const documentSearchStatus = computed(() => {
|
||||
const count = documentSearchMatches.value.length;
|
||||
return count > 0 ? `${documentSearchActiveIndex.value + 1}/${count}` : "0/0";
|
||||
});
|
||||
|
||||
const editKeyWidth = computed(() => {
|
||||
const longest = editFields.value.reduce((max, field) => {
|
||||
@@ -1591,15 +1575,12 @@ function docPreview(doc: JsonRecord): string {
|
||||
return `${id} - ${preview}`;
|
||||
}
|
||||
|
||||
function highlightedJson(json: string): string {
|
||||
return renderDocumentJsonHtml(json, documentSearchOpen.value ? documentSearchQuery.value : "", documentSearchActiveIndex.value);
|
||||
}
|
||||
|
||||
function handleDocumentViewerDoubleClick(event: MouseEvent) {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
const jsonViewer = target.closest(".json-viewer");
|
||||
if (jsonViewer && target !== jsonViewer) return;
|
||||
// CodeMirror uses .cm-line for rendered document content. Preserve the
|
||||
// existing shortcut only for whitespace around the source, not text itself.
|
||||
if (target.closest(".cm-line")) return;
|
||||
const selection = window.getSelection();
|
||||
if (selection && !selection.isCollapsed && selection.toString()) return;
|
||||
startEdit();
|
||||
@@ -1614,55 +1595,12 @@ function focusSearch(): boolean {
|
||||
if (viewMode.value !== "document" || !documentViewerSearchActive.value) return false;
|
||||
if (isEditing.value) return false;
|
||||
if (!isNew.value && selectedIdx.value === null) return false;
|
||||
documentSearchOpen.value = true;
|
||||
documentSearchHasNavigated.value = false;
|
||||
void nextTick(() => {
|
||||
documentSearchInputRef.value?.focus();
|
||||
documentSearchInputRef.value?.select();
|
||||
});
|
||||
return true;
|
||||
return documentJsonEditorRef.value?.openSearch() ?? false;
|
||||
}
|
||||
|
||||
function closeDocumentSearch() {
|
||||
documentSearchOpen.value = false;
|
||||
void nextTick(() => {
|
||||
documentViewerRef.value?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function moveDocumentSearchMatch(delta: -1 | 1) {
|
||||
const count = documentSearchMatches.value.length;
|
||||
if (count === 0) return;
|
||||
documentSearchMatchIndex.value = (documentSearchActiveIndex.value + delta + count) % count;
|
||||
documentSearchHasNavigated.value = true;
|
||||
void scrollDocumentSearchMatchIntoView();
|
||||
}
|
||||
|
||||
function activateDocumentSearchMatch(delta: -1 | 1) {
|
||||
if (documentSearchMatches.value.length === 0) return;
|
||||
if (!documentSearchHasNavigated.value) {
|
||||
documentSearchHasNavigated.value = true;
|
||||
void scrollDocumentSearchMatchIntoView();
|
||||
return;
|
||||
}
|
||||
moveDocumentSearchMatch(delta);
|
||||
}
|
||||
|
||||
async function scrollDocumentSearchMatchIntoView() {
|
||||
await nextTick();
|
||||
documentViewerRef.value?.querySelector<HTMLElement>('[data-document-search-active="true"]')?.scrollIntoView({ block: "center", inline: "nearest" });
|
||||
}
|
||||
|
||||
watch([documentSearchQuery, documentSearchText], () => {
|
||||
documentSearchMatchIndex.value = 0;
|
||||
documentSearchHasNavigated.value = false;
|
||||
void scrollDocumentSearchMatchIntoView();
|
||||
});
|
||||
|
||||
watch([viewMode, isEditing, selectedIdx], ([mode, editing, index]) => {
|
||||
if (mode === "document" && !editing && index !== null) return;
|
||||
documentViewerSearchActive.value = false;
|
||||
documentSearchOpen.value = false;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -2255,23 +2193,6 @@ defineExpose({ focusSearch });
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="documentSearchOpen && (!isEditing || documentEditMode === 'json')" data-document-search class="flex h-9 shrink-0 items-center justify-end gap-1 border-b bg-background px-2">
|
||||
<div class="relative w-56 max-w-[45%] min-w-32">
|
||||
<Search class="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input ref="documentSearchInputRef" v-model="documentSearchQuery" class="h-7 select-text pl-7 pr-2 text-xs" :placeholder="t('editor.search.find')" @keydown.enter.prevent="activateDocumentSearchMatch($event.shiftKey ? -1 : 1)" @keydown.escape.prevent="closeDocumentSearch" />
|
||||
</div>
|
||||
<span class="w-12 text-center text-[11px] tabular-nums text-muted-foreground">{{ documentSearchStatus }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('editor.search.prevMatch')" :disabled="documentSearchMatches.length === 0" @click="moveDocumentSearchMatch(-1)">
|
||||
<ChevronUp class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('editor.search.nextMatch')" :disabled="documentSearchMatches.length === 0" @click="moveDocumentSearchMatch(1)">
|
||||
<ChevronDown class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="closeDocumentSearch">
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="isEditing && documentEditMode === 'json' && !isNew" class="px-4 py-1.5 text-[11px] text-muted-foreground border-b bg-muted/20 shrink-0">
|
||||
{{ t("mongo.jsonReplaceHint") }}
|
||||
</div>
|
||||
@@ -2293,8 +2214,8 @@ defineExpose({ focusSearch });
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else ref="documentViewerRef" data-document-json-viewer tabindex="-1" class="flex-1 overflow-auto bg-muted/10 outline-none" @dblclick="handleDocumentViewerDoubleClick">
|
||||
<pre class="json-viewer min-w-fit select-text p-5" :style="documentFontStyle" v-html="highlightedJson(editJson)" />
|
||||
<div v-else data-document-json-viewer class="flex-1 min-h-0 bg-muted/10 outline-none" @dblclick="handleDocumentViewerDoubleClick">
|
||||
<RedisJsonEditor ref="documentJsonEditorRef" :model-value="editJson" read-only :line-numbers="false" presentation="viewer" class="h-full" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
@@ -2319,15 +2240,6 @@ defineExpose({ focusSearch });
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.json-viewer {
|
||||
font-family: var(--dbx-editor-font-family);
|
||||
font-size: var(--dbx-editor-font-size);
|
||||
line-height: 1.6;
|
||||
tab-size: 2;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.json-edit {
|
||||
font-family: var(--dbx-editor-font-family);
|
||||
font-size: var(--dbx-editor-font-size);
|
||||
|
||||
+32
-3
@@ -11,6 +11,10 @@ const backend = vi.hoisted(() => ({
|
||||
documentDeleteDocument: vi.fn(),
|
||||
}));
|
||||
|
||||
const documentJsonEditor = vi.hoisted(() => ({
|
||||
openSearch: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
const settings = vi.hoisted(() => ({
|
||||
editorSettings: {
|
||||
pageSize: 100,
|
||||
@@ -106,6 +110,24 @@ vi.mock("@/components/grid/DataGrid.vue", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/redis/RedisJsonEditor.vue", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
default: defineComponent({
|
||||
props: {
|
||||
modelValue: { type: String, required: true },
|
||||
readOnly: { type: Boolean, default: false },
|
||||
lineNumbers: { type: Boolean, default: true },
|
||||
presentation: { type: String, default: "editor" },
|
||||
},
|
||||
setup(props, { expose }) {
|
||||
expose({ openSearch: documentJsonEditor.openSearch });
|
||||
return () => h("div", { "data-redis-json-editor-stub": "", "data-read-only": String(props.readOnly), "data-line-numbers": String(props.lineNumbers), "data-presentation": props.presentation }, [h("div", { class: "cm-line" }, h("span", { class: "json-string" }, props.modelValue))]);
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/popover", async () => {
|
||||
const { computed, defineComponent, h, inject, provide } = await import("vue");
|
||||
type PopoverContext = {
|
||||
@@ -236,6 +258,7 @@ beforeEach(async () => {
|
||||
backend.cancelQuery.mockReset();
|
||||
backend.ensureConnected.mockReset();
|
||||
backend.documentDeleteDocument.mockReset();
|
||||
documentJsonEditor.openSearch.mockClear();
|
||||
backend.documentDeleteDocument.mockResolvedValue(undefined);
|
||||
settings.editorSettings.mongoViewMode = "table";
|
||||
settings.editorSettings.columnWidthDensity = "standard";
|
||||
@@ -532,7 +555,7 @@ describe("DocumentBrowser MongoDB filter value types", () => {
|
||||
collection: "typed_ids",
|
||||
databaseType: "mongodb",
|
||||
});
|
||||
app.mount(root!);
|
||||
const documentBrowser = app.mount(root!) as unknown as { focusSearch: () => boolean };
|
||||
await flushUi();
|
||||
|
||||
const documentRow = [...root!.querySelectorAll<HTMLElement>(".group")].find((element) => element.textContent?.includes("document-1"))!;
|
||||
@@ -540,7 +563,7 @@ describe("DocumentBrowser MongoDB filter value types", () => {
|
||||
await flushUi();
|
||||
|
||||
const viewer = root!.querySelector<HTMLElement>("[data-document-json-viewer]")!;
|
||||
const jsonText = viewer.querySelector<HTMLElement>(".json-string")!;
|
||||
const jsonText = viewer.querySelector<HTMLElement>(".cm-line .json-string")!;
|
||||
const documentId = root!.querySelector<HTMLInputElement>('input[aria-label^="_id:"]')!;
|
||||
expect(root!.firstElementChild?.classList.contains("select-none")).toBe(true);
|
||||
expect(documentId.readOnly).toBe(true);
|
||||
@@ -550,7 +573,9 @@ describe("DocumentBrowser MongoDB filter value types", () => {
|
||||
expect(documentIdBadge).not.toBeNull();
|
||||
expect(documentIdBadge?.classList.contains("rounded")).toBe(true);
|
||||
expect(documentIdBadge?.classList.contains("rounded-4xl")).toBe(false);
|
||||
expect(viewer.querySelector(".json-viewer")?.classList.contains("select-text")).toBe(true);
|
||||
expect(viewer.querySelector<HTMLElement>("[data-redis-json-editor-stub]")?.dataset.readOnly).toBe("true");
|
||||
expect(viewer.querySelector<HTMLElement>("[data-redis-json-editor-stub]")?.dataset.lineNumbers).toBe("false");
|
||||
expect(viewer.querySelector<HTMLElement>("[data-redis-json-editor-stub]")?.dataset.presentation).toBe("viewer");
|
||||
|
||||
documentId.setSelectionRange(0, documentId.value.length);
|
||||
expect(documentId.selectionStart).toBe(0);
|
||||
@@ -561,6 +586,10 @@ describe("DocumentBrowser MongoDB filter value types", () => {
|
||||
await flushUi();
|
||||
expect(buttonWithText("mongo.edit")).toBeDefined();
|
||||
|
||||
viewer.dispatchEvent(new Event("pointerdown", { bubbles: true }));
|
||||
expect(documentBrowser.focusSearch()).toBe(true);
|
||||
expect(documentJsonEditor.openSearch).toHaveBeenCalledOnce();
|
||||
|
||||
viewer.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
|
||||
await flushUi();
|
||||
expect(buttonWithText("grid.save")).toBeDefined();
|
||||
|
||||
@@ -14,6 +14,10 @@ const props = withDefaults(
|
||||
saveDisabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
wordWrap?: boolean;
|
||||
/** Document previews keep folding controls but do not need source line numbers. */
|
||||
lineNumbers?: boolean;
|
||||
/** A lighter reading surface for read-only JSON previews. */
|
||||
presentation?: "editor" | "viewer";
|
||||
/**
|
||||
* When false, Mod+F is left to a parent find surface (RedisValueViewer).
|
||||
* Default true so DocumentBrowser and other callers keep CodeMirror find.
|
||||
@@ -24,6 +28,8 @@ const props = withDefaults(
|
||||
saveDisabled: false,
|
||||
readOnly: false,
|
||||
wordWrap: false,
|
||||
lineNumbers: true,
|
||||
presentation: "editor",
|
||||
enableBuiltinFind: true,
|
||||
},
|
||||
);
|
||||
@@ -39,7 +45,7 @@ const { isDark, themePalette } = useTheme();
|
||||
|
||||
const editor = useCellDetailEditor({
|
||||
language: "json",
|
||||
lineNumbers: true,
|
||||
lineNumbers: props.lineNumbers,
|
||||
folding: true,
|
||||
lineWrapping: () => props.wordWrap,
|
||||
readOnly: () => props.readOnly,
|
||||
@@ -85,5 +91,39 @@ defineExpose({ openSearch, selectRange });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="editorContainer" class="h-full min-h-0 w-full" data-redis-json-editor />
|
||||
<div ref="editorContainer" class="h-full min-h-0 w-full" :class="{ 'redis-json-editor--viewer': presentation === 'viewer' }" data-redis-json-editor />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.redis-json-editor--viewer :deep(.cm-editor) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.redis-json-editor--viewer :deep(.cm-scroller) {
|
||||
padding-block: 0.75rem;
|
||||
}
|
||||
|
||||
.redis-json-editor--viewer :deep(.cm-content) {
|
||||
padding: 0 1rem 1rem 0.375rem;
|
||||
}
|
||||
|
||||
.redis-json-editor--viewer :deep(.cm-gutters) {
|
||||
background: transparent;
|
||||
border-right: 1px solid var(--border);
|
||||
padding-inline: 0.25rem;
|
||||
}
|
||||
|
||||
.redis-json-editor--viewer :deep(.cm-foldGutter .cm-gutterElement) {
|
||||
border-radius: 3px;
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
.redis-json-editor--viewer :deep(.cm-foldGutter .cm-gutterElement:hover) {
|
||||
background: var(--accent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
</style>
|
||||
@@ -10,6 +10,7 @@ const cellDetailEditorSource = readFileSync(new URL("../useCellDetailEditor.ts",
|
||||
describe("Redis JSON editor folding", () => {
|
||||
it("opts the Redis JSON editor into the shared folding controls", () => {
|
||||
expect(redisJsonEditorSource).toMatch(/language:\s*"json"[\s\S]*folding:\s*true/);
|
||||
expect(redisJsonEditorSource).toMatch(/lineNumbers:\s*props\.lineNumbers/);
|
||||
expect(cellDetailEditorSource).toContain("...(options.folding ? [foldGutter()] : []),");
|
||||
expect(cellDetailEditorSource).toContain("...(options.folding ? foldKeymap : []),");
|
||||
});
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
export interface DocumentTextMatch {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
type JsonTokenRange = DocumentTextMatch & { className: string };
|
||||
|
||||
const JSON_TOKEN_PATTERN = /("(?:\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g;
|
||||
|
||||
export function findDocumentTextMatches(text: string, query: string): DocumentTextMatch[] {
|
||||
if (!query) return [];
|
||||
const haystack = text.toLowerCase();
|
||||
const needle = query.toLowerCase();
|
||||
const matches: DocumentTextMatch[] = [];
|
||||
let start = 0;
|
||||
|
||||
while (start <= haystack.length - needle.length) {
|
||||
const index = haystack.indexOf(needle, start);
|
||||
if (index < 0) break;
|
||||
matches.push({ start: index, end: index + needle.length });
|
||||
start = index + Math.max(needle.length, 1);
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function renderDocumentJsonHtml(json: string, query = "", activeMatchIndex = 0): string {
|
||||
const tokenRanges = jsonTokenRanges(json);
|
||||
const searchRanges = findDocumentTextMatches(json, query);
|
||||
const boundaries = new Set<number>([0, json.length]);
|
||||
|
||||
for (const range of tokenRanges) {
|
||||
boundaries.add(range.start);
|
||||
boundaries.add(range.end);
|
||||
}
|
||||
for (const range of searchRanges) {
|
||||
boundaries.add(range.start);
|
||||
boundaries.add(range.end);
|
||||
}
|
||||
|
||||
const points = [...boundaries].sort((left, right) => left - right);
|
||||
let tokenIndex = 0;
|
||||
let searchIndex = 0;
|
||||
let html = "";
|
||||
|
||||
for (let index = 0; index < points.length - 1; index += 1) {
|
||||
const start = points[index];
|
||||
const end = points[index + 1];
|
||||
if (end <= start) continue;
|
||||
|
||||
while (tokenRanges[tokenIndex]?.end <= start) tokenIndex += 1;
|
||||
while (searchRanges[searchIndex]?.end <= start) searchIndex += 1;
|
||||
|
||||
const token = tokenRanges[tokenIndex];
|
||||
const search = searchRanges[searchIndex];
|
||||
let segment = escapeHtml(json.slice(start, end));
|
||||
|
||||
if (token && token.start <= start && token.end >= end) {
|
||||
segment = `<span class="${token.className}">${segment}</span>`;
|
||||
}
|
||||
if (search && search.start <= start && search.end >= end) {
|
||||
const activeClass = searchIndex === activeMatchIndex ? " document-search-match-active" : "";
|
||||
const activeAttribute = searchIndex === activeMatchIndex ? ' data-document-search-active="true"' : "";
|
||||
segment = `<mark class="document-search-match${activeClass}" data-document-search-match="${searchIndex}"${activeAttribute}>${segment}</mark>`;
|
||||
}
|
||||
|
||||
html += segment;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function jsonTokenRanges(json: string): JsonTokenRange[] {
|
||||
const ranges: JsonTokenRange[] = [];
|
||||
const pattern = new RegExp(JSON_TOKEN_PATTERN.source, "g");
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = pattern.exec(json))) {
|
||||
ranges.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
className: jsonTokenClass(match[0]),
|
||||
});
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function jsonTokenClass(token: string): string {
|
||||
if (token.startsWith('"')) return token.endsWith(":") ? "json-key" : "json-string";
|
||||
if (token === "true" || token === "false") return "json-boolean";
|
||||
if (token === "null") return "json-null";
|
||||
return "json-number";
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
Reference in new issue
Block a user