diff --git a/package.json b/package.json index f3befb288..544e2f2b6 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,6 @@ "bencode": "^4.0.0", "chardet": "^2.1.1", "clsx": "^2.1.1", - "copy-to-clipboard": "^3.3.3", "crypto-js": "^4.2.0", "docx-preview": "^0.3.7", "handlebars": "^4.7.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad0444211..238b059c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,9 +71,6 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 - copy-to-clipboard: - specifier: ^3.3.3 - version: 3.3.3 crypto-js: specifier: ^4.2.0 version: 4.2.0 @@ -1962,9 +1959,6 @@ packages: convert-string@0.1.0: resolution: {integrity: sha512-1KX9ESmtl8xpT2LN2tFnKSbV4NiarbVi8DVb39ZriijvtTklyrT+4dT1wsGMHKD3CJUjXgvJzstm9qL9ICojGA==} - copy-to-clipboard@3.3.3: - resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} - core-js-compat@3.48.0: resolution: {integrity: sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==} @@ -3389,9 +3383,6 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} - toggle-selection@1.0.6: - resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==} - tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -5595,10 +5586,6 @@ snapshots: convert-string@0.1.0: {} - copy-to-clipboard@3.3.3: - dependencies: - toggle-selection: 1.0.6 - core-js-compat@3.48.0: dependencies: browserslist: 4.28.1 @@ -7278,8 +7265,6 @@ snapshots: dependencies: is-number: 7.0.0 - toggle-selection@1.0.6: {} - tr46@0.0.3: {} trim-lines@3.0.1: {} diff --git a/src/components/EncodingSelect.tsx b/src/components/EncodingSelect.tsx index ae79f1ccb..d4d85c4b9 100644 --- a/src/components/EncodingSelect.tsx +++ b/src/components/EncodingSelect.tsx @@ -3,70 +3,114 @@ import { SelectWrapper } from "./Base" import chardet from "chardet" import { createEffect } from "solid-js" +const MIN_CONFIDENCE = 30 + +const encodingLabels = [ + "utf-8", + "gbk", + "gb18030", + "ibm866", + "iso-8859-2", + "iso-8859-3", + "iso-8859-4", + "iso-8859-5", + "iso-8859-6", + "iso-8859-7", + "iso-8859-8", + "iso-8859-8i", + "iso-8859-10", + "iso-8859-13", + "iso-8859-14", + "iso-8859-15", + "iso-8859-16", + "koi8-r", + "koi8-u", + "macintosh", + "windows-874", + "windows-1250", + "windows-1251", + "windows-1252", + "windows-1253", + "windows-1254", + "windows-1255", + "windows-1256", + "windows-1257", + "windows-1258", + "x-mac-cyrillic", + "big5", + "euc-jp", + "iso-2022-jp", + "shift_jis", + "euc-kr", + "iso-2022-kr", + "utf-16be", + "utf-16le", + "x-user-defined", + "iso-2022-cn", +] + +const encodingLabelSet = new Set(encodingLabels) + +/** + * Detect encoding from BOM (Byte Order Mark). + * Returns the detected encoding or undefined if no BOM found. + */ +function detectBOM(buffer: Uint8Array): string | undefined { + if ( + buffer.length >= 3 && + buffer[0] === 0xef && + buffer[1] === 0xbb && + buffer[2] === 0xbf + ) { + return "utf-8" + } + if (buffer.length >= 2) { + if (buffer[0] === 0xff && buffer[1] === 0xfe) return "utf-16le" + if (buffer[0] === 0xfe && buffer[1] === 0xff) return "utf-16be" + } + return undefined +} + +/** + * Detect encoding using chardet with confidence filtering. + * Returns the best match or undefined if no confident match found. + */ +function detectByChardet(buffer: Uint8Array): string | undefined { + const results = chardet.analyse(buffer) + for (const result of results) { + if (result.confidence >= MIN_CONFIDENCE) { + const label = result.name.toLowerCase() + if (encodingLabelSet.has(label)) { + return label + } + } + } + return undefined +} + export function EncodingSelect(props: { encoding: string setEncoding: (encoding: string) => void referenceText?: string | ArrayBuffer }) { - const encodingLabels = [ - "utf-8", - "gbk", - "gb18030", - "ibm866", - "iso-8859-2", - "iso-8859-3", - "iso-8859-4", - "iso-8859-5", - "iso-8859-6", - "iso-8859-7", - "iso-8859-8", - "iso-8859-8i", - "iso-8859-10", - "iso-8859-13", - "iso-8859-14", - "iso-8859-15", - "iso-8859-16", - "koi8-r", - "koi8-u", - "macintosh", - "windows-874", - "windows-1250", - "windows-1251", - "windows-1252", - "windows-1253", - "windows-1254", - "windows-1255", - "windows-1256", - "windows-1257", - "windows-1258", - "x-mac-cyrillic", - "big5", - "euc-jp", - "iso-2022-jp", - "shift_jis", - "euc-kr", - "iso-2022-kr", - "utf-16be", - "utf-16le", - "x-user-defined", - "iso-2022-cn", - ] - createEffect(() => { - if (props.referenceText) { - let buffer: Uint8Array - if (typeof props.referenceText === "string") { - buffer = new TextEncoder().encode(props.referenceText) - } else { - buffer = new Uint8Array(props.referenceText) - } - for (let encoding of chardet.analyse(buffer)) { - const encodingLabel = encoding.name.toLowerCase() - if (encodingLabels.includes(encodingLabel)) { - props.setEncoding(encodingLabel) - return - } - } + const data = props.referenceText + // Skip detection for strings - they are already decoded (TextEncoder always produces UTF-8) + if (!data || typeof data === "string") return + + const buffer = new Uint8Array(data) + + // 1. Try BOM detection first (fast and accurate) + const bomEncoding = detectBOM(buffer) + if (bomEncoding) { + props.setEncoding(bomEncoding) + return + } + + // 2. Fall back to chardet with confidence filtering + const detected = detectByChardet(buffer) + if (detected) { + props.setEncoding(detected) } }) diff --git a/src/components/MonacoEditor.tsx b/src/components/MonacoEditor.tsx index eeb9cc1df..d704a359f 100644 --- a/src/components/MonacoEditor.tsx +++ b/src/components/MonacoEditor.tsx @@ -1,5 +1,5 @@ import { Box } from "@hope-ui/solid" -import { createEffect, createSignal, onCleanup, onMount } from "solid-js" +import { createEffect, createSignal, on, onCleanup, onMount } from "solid-js" import { MaybeLoading } from "./FullLoading" import loader from "@monaco-editor/loader" import { useCDN } from "~/hooks" @@ -9,11 +9,12 @@ import { local } from "~/store" export interface MonacoEditorProps { value: string onChange?: (value: string) => void - theme: "vs" | "vs-dark" path?: string language?: string + options?: monacoType.editor.IStandaloneEditorConstructionOptions + onEditorReady?: (editor: monacoType.editor.IStandaloneCodeEditor) => void } -let monaco: typeof monacoType +export let monaco: typeof monacoType export const MonacoEditorLoader = (props: MonacoEditorProps) => { const { monacoPath } = useCDN() @@ -40,11 +41,13 @@ export const MonacoEditor = (props: MonacoEditorProps) => { let model: monacoType.editor.ITextModel onMount(() => { - monacoEditor = monaco.editor.create(monacoEditorDiv!, { + const constructionOptions = { + ...props.options, value: props.value, - theme: props.theme, fontSize: parseInt(local.editor_font_size), - }) + automaticLayout: true, + } + monacoEditor = monaco.editor.create(monacoEditorDiv!, constructionOptions) model = monaco.editor.createModel( props.value, props.language, @@ -54,18 +57,38 @@ export const MonacoEditor = (props: MonacoEditorProps) => { monacoEditor.onDidChangeModelContent(() => { props.onChange?.(monacoEditor.getValue()) }) + props.onEditorReady?.(monacoEditor) }) - createEffect(() => { - monacoEditor.setValue(props.value) - }) + createEffect( + on( + () => props.value, + (value) => { + monacoEditor.setValue(value) + }, + { defer: true }, + ), + ) createEffect(() => { - monaco.editor.setTheme(props.theme) + monaco.editor.setTheme(props.options?.theme ?? "vs") }) + createEffect( + on( + () => props.language, + (lang) => { + if (lang && model) { + monaco.editor.setModelLanguage(model, lang) + } + }, + { defer: true }, + ), + ) + createEffect(() => { monacoEditor?.updateOptions({ fontSize: parseInt(local.editor_font_size), + ...props.options, }) }) @@ -73,5 +96,5 @@ export const MonacoEditor = (props: MonacoEditorProps) => { model && model.dispose() monacoEditor && monacoEditor.dispose() }) - return + return } diff --git a/src/hooks/useUtil.ts b/src/hooks/useUtil.ts index 696073f93..7c60c6500 100644 --- a/src/hooks/useUtil.ts +++ b/src/hooks/useUtil.ts @@ -1,18 +1,58 @@ -import copy from "copy-to-clipboard" import { createResource } from "solid-js" import { getHideFiles, objStore } from "~/store" import { Obj } from "~/types" import { decodeText, fetchText, notify, pathJoin } from "~/utils" import { useT, useLink, useRouter } from "." +async function checkClipboardPermission( + mode: "clipboard-read" | "clipboard-write", +): Promise { + try { + const status = await navigator.permissions.query({ + name: mode as PermissionName, + }) + return status.state === "granted" || status.state === "prompt" + } catch { + // Safari does not support permissions.query for clipboard; + // assume permission is available if navigator.clipboard exists. + return !!navigator.clipboard + } +} + export const useUtil = () => { const t = useT() const { pathname } = useRouter() return { - copy: (text: string) => { - copy(text) + copy: async (text: string) => { + try { + if (await checkClipboardPermission("clipboard-write")) { + await navigator.clipboard.writeText(text) + } else { + throw new Error("permission denied") + } + } catch { + const ta = document.createElement("textarea") + ta.value = text + ta.style.position = "fixed" + ta.style.opacity = "0" + document.body.appendChild(ta) + ta.select() + document.execCommand("copy") + document.body.removeChild(ta) + } notify.success(t("global.copied")) }, + paste: async (): Promise => { + try { + if (!(await checkClipboardPermission("clipboard-read"))) { + throw new Error(t("global.clipboard_denied")) + } + return navigator.clipboard.readText() + } catch (e: any) { + notify.error(e.message || t("global.clipboard_denied")) + return "" + } + }, isHide: (obj: Obj) => { const hideFiles = getHideFiles() for (const reg of hideFiles) { diff --git a/src/lang/en/global.json b/src/lang/en/global.json index 7561b2da5..a622d2a21 100644 --- a/src/lang/en/global.json +++ b/src/lang/en/global.json @@ -5,7 +5,14 @@ "delete": "Delete", "save": "Save", "update": "Update", + "undo": "Undo", + "redo": "Redo", + "increase": "Increase", + "decrease": "Decrease", + "copy": "Copy", + "paste": "Paste", "copied": "Copied", + "clipboard_denied": "Clipboard access denied. Please allow clipboard permission in your browser settings.", "delete_success": "Deleted Successfully", "save_success": "Saved Successfully", "update_success": "Updated Successfully", @@ -28,6 +35,7 @@ "close": "Close", "no_support_now": "Not currently supported", "empty_input": "Please enter", + "unsaved_changes_confirm": "You have unsaved changes. Are you sure you want to leave?", "invalid_filename_chars": "Filename cannot contain: / \\ ? < > * : | \"", "invalid_regex": "Invalid regular expression", "name": "Name", diff --git a/src/lang/en/home.json b/src/lang/en/home.json index 104cc2e83..cb4fab2e8 100644 --- a/src/lang/en/home.json +++ b/src/lang/en/home.json @@ -229,6 +229,17 @@ "dblclick": "Double click", "disable_while_checked": "Disable while checked" }, + "editor_font_size": "Editor font size", + "editor_word_wrap": "Editor word wrap", + "editor_word_wrap_options": { + "false": "Off", + "true": "On" + }, + "editor_minimap": "Editor minimap", + "editor_minimap_options": { + "false": "Off", + "true": "On" + }, "show_gallery_thumbnails": "Show gallery thumbnails", "show_gallery_thumbnails_options": { "none": "None", diff --git a/src/pages/home/previews/text-editor.tsx b/src/pages/home/previews/text-editor.tsx index 1074bebf3..6ec762671 100644 --- a/src/pages/home/previews/text-editor.tsx +++ b/src/pages/home/previews/text-editor.tsx @@ -1,12 +1,54 @@ -import { Button, useColorMode, VStack } from "@hope-ui/solid" -import { createEffect, createMemo, createSignal, on, Show } from "solid-js" -import { EncodingSelect, MaybeLoading } from "~/components" -import { MonacoEditorLoader } from "~/components/MonacoEditor" -import { useFetch, useFetchText, useParseText, useRouter, useT } from "~/hooks" -import { objStore, userCan } from "~/store" -import { PEmptyResp } from "~/types" -import { handleResp, notify, r } from "~/utils" +import { + Box, + Button, + ButtonGroup, + HStack, + IconButton, + Input, + Popover, + PopoverBody, + PopoverContent, + PopoverTrigger, + Tooltip, + useColorMode, + VStack, +} from "@hope-ui/solid" +import { + createEffect, + createMemo, + createSignal, + For, + on, + onCleanup, + Show, +} from "solid-js" +import { useBeforeLeave } from "@solidjs/router" +import { BoxWithFullScreen, EncodingSelect, MaybeLoading } from "~/components" +import { MonacoEditorLoader, monaco } from "~/components/MonacoEditor" +import { useFetchText, useParseText, useRouter, useT, useUtil } from "~/hooks" +import { objStore, setLocal, userCan } from "~/store" +import { local } from "~/store" +import { notify } from "~/utils" +import { StreamUpload } from "~/pages/home/uploads/stream" import { createShortcut } from "@solid-primitives/keyboard" +import { BiRegularRedo, BiRegularUndo } from "solid-icons/bi" +import { + TbBraces, + TbClipboardText, + TbCopy, + TbDeviceFloppy, + TbTextWrap, + TbTextWrapDisabled, + TbMap, + TbMapOff, +} from "solid-icons/tb" +import { FaSolidMinus, FaSolidPlus } from "solid-icons/fa" +import type * as monacoType from "monaco-editor/esm/vs/editor/editor.api.js" + +interface LanguageOption { + id: string + aliases?: string[] +} function Editor(props: { data?: string | ArrayBuffer; contentType?: string }) { const { colorMode } = useColorMode() @@ -18,68 +60,463 @@ function Editor(props: { data?: string | ArrayBuffer; contentType?: string }) { const [encoding, setEncoding] = createSignal("utf-8") const [value, setValue] = createSignal(text(encoding())) const t = useT() - const [loading, save] = useFetch( - (): PEmptyResp => - r.put("/fs/put", value(), { - headers: { - "File-Path": encodeURIComponent(pathname()), - "Content-Type": props.contentType || "text/plain", - }, - }), + const { copy, paste } = useUtil() + + // Editor instance reference + const [editor, setEditor] = + createSignal() + + // Track modified state + const [modified, setModified] = createSignal(false) + const [cursorLine, setCursorLine] = createSignal(1) + const [cursorColumn, setCursorColumn] = createSignal(1) + const [wordCount, setWordCount] = createSignal(0) + const [language, setLanguage] = createSignal("") + const [languageOptions, setLanguageOptions] = createSignal( + [], + ) + const [wordWrap, setWordWrap] = createSignal( + local.editor_word_wrap === "true", + ) + const [minimap, setMinimap] = createSignal(local.editor_minimap !== "false") + const [saving, setSaving] = createSignal(false) + const [langSearch, setLangSearch] = createSignal("") + const filteredLanguages = createMemo(() => { + const s = langSearch().toLowerCase() + if (!s) return languageOptions() + return languageOptions().filter( + (l) => + l.id.toLowerCase().includes(s) || + l.aliases?.some((a) => a.toLowerCase().includes(s)), + ) + }) + const languageDisplayName = createMemo(() => { + const lang = languageOptions().find((l) => l.id === language()) + return lang?.aliases?.[0] || language() + }) + const canWrite = createMemo( + () => + // objStore.write is only set from folder listing (FsListResp), + // not from file detail (FsGetResp). When directly entering a file, + // write is undefined, so fall back to permission check only. + (userCan("write_content") || objStore.write_content_bypass) && + objStore.write !== false, ) + + if (canWrite()) { + // Warn on browser close/refresh when there are unsaved changes + const beforeUnloadHandler = (e: BeforeUnloadEvent) => { + if (modified()) { + e.preventDefault() + } + } + window.addEventListener("beforeunload", beforeUnloadHandler) + onCleanup(() => + window.removeEventListener("beforeunload", beforeUnloadHandler), + ) + + // Warn on in-app navigation when there are unsaved changes + useBeforeLeave((e) => { + if (modified()) { + if (!window.confirm(t("global.unsaved_changes_confirm"))) { + e.preventDefault() + } + } + }) + + // Save on Ctrl+S / Cmd+S + createShortcut(["Control", "S"], (e: KeyboardEvent | null) => { + e?.preventDefault() + onSave() + }) + createShortcut(["Meta", "S"], (e: KeyboardEvent | null) => { + e?.preventDefault() + onSave() + }) + } + createEffect( on(encoding, (v) => { setValue(text(v)) + setModified(false) }), ) async function onSave() { - const resp = await save() - handleResp(resp, () => { + setSaving(true) + try { + const file = new File([value()], objStore.obj.name, { + type: props.contentType || "text/plain", + }) + await StreamUpload(pathname(), file, () => {}, false, true, false) notify.success(t("global.save_success")) + setModified(false) + } catch (e: any) { + notify.error(e.message) + } finally { + setSaving(false) + } + } + + function onEditorReady(ed: monacoType.editor.IStandaloneCodeEditor) { + setEditor(ed) + + // Track cursor position + ed.onDidChangeCursorPosition((e) => { + setCursorLine(e.position.lineNumber) + setCursorColumn(e.position.column) }) + + // Track content changes for modified state + let savedVersionId = ed.getModel()?.getAlternativeVersionId() ?? 0 + ed.onDidChangeModelContent(() => { + const currentVersionId = ed.getModel()?.getAlternativeVersionId() ?? 0 + setModified(currentVersionId !== savedVersionId) + updateWordCount(ed) + }) + + // Initial word count + updateWordCount(ed) + + // Detect language from model + const lang = ed.getModel()?.getLanguageId() ?? "" + setLanguage(lang) + + // Populate language options from Monaco + if (monaco?.languages?.getLanguages) { + const langs = monaco.languages.getLanguages() as LanguageOption[] + setLanguageOptions(langs.sort((a, b) => a.id.localeCompare(b.id))) + } + } + + function updateWordCount(ed: monacoType.editor.IStandaloneCodeEditor) { + const content = ed.getValue() + if (!content) { + setWordCount(0) + return + } + const trimmed = content.trim() + setWordCount(trimmed ? trimmed.split(/\s+/).length : 0) } - createShortcut(["Control", "S"], onSave) + function undo() { + editor()?.trigger("toolbar", "undo", null) + } + + function redo() { + editor()?.trigger("toolbar", "redo", null) + } + + async function copyToClipboard() { + const ed = editor() + if (!ed) return + const sel = ed.getSelection() + const selection = sel ? (ed.getModel()?.getValueInRange(sel) ?? "") : "" + const text = selection || ed.getValue() + await copy(text) + } + + async function pasteFromClipboard() { + const ed = editor() + if (!ed) return + + const text = await paste() + if (!text) return + const selection = ed.getSelection() + if (selection) { + ed.executeEdits("paste", [ + { range: selection, text, forceMoveMarkers: true }, + ]) + } else { + ed.trigger("keyboard", "paste", { text }) + } + ed.focus() + } + + function toggleWordWrap() { + const next = !wordWrap() + setWordWrap(next) + setLocal("editor_word_wrap", String(next)) + } + + function changeFontSize(delta: number) { + const current = parseInt(local.editor_font_size) || 14 + const next = Math.max(8, Math.min(40, current + delta)) + setLocal("editor_font_size", String(next)) + } return ( - - - - + + {/* Toolbar */} + + + + + + + + } + size="sm" + variant="ghost" + onClick={undo} + /> + + + } + size="sm" + variant="ghost" + onClick={redo} + /> + + + + + + + } + size="sm" + variant="ghost" + onClick={copyToClipboard} + /> + + + + } + size="sm" + variant="ghost" + onClick={pasteFromClipboard} + /> + + + + + + + : } + size="sm" + variant="ghost" + onClick={toggleWordWrap} + color={wordWrap() ? "$info11" : undefined} + /> + + + + : } + size="sm" + variant="ghost" + onClick={() => { + const next = !minimap() + setMinimap(next) + setLocal("editor_minimap", String(next)) + }} + color={minimap() ? "$info11" : undefined} + /> + + + + + + + } + onClick={() => changeFontSize(-1)} + /> + + + + } + onClick={() => changeFontSize(1)} + /> + + + + + + + + + + + {/* Editor */} { - setValue(value) + options={{ + theme: theme(), + wordWrap: wordWrap() ? "on" : "off", + minimap: { enabled: minimap() }, + readOnly: !canWrite(), }} + onChange={(val) => setValue(val)} + onEditorReady={onEditorReady} /> - - - + + setLangSearch("")}> + + + + {languageDisplayName()} + + + + + setLangSearch(e.currentTarget.value)} + mb="$2" + autofocus + /> + + + {(lang) => ( + { + setLanguage(lang.id) + setLangSearch("") + }} + > + {lang.aliases?.[0] || lang.id} + + + ({lang.id}) + + + + )} + + + + + + + + + Ln {cursorLine()}, Col {cursorColumn()} + + {wordCount()} words + + + ● + + + ) } -// TODO add encoding select const TextEditor = () => { const [content] = useFetchText() return ( - - - + + + + + ) } diff --git a/src/pages/home/toolbar/LocalSettings.tsx b/src/pages/home/toolbar/LocalSettings.tsx index 4779ce50b..84bebce37 100644 --- a/src/pages/home/toolbar/LocalSettings.tsx +++ b/src/pages/home/toolbar/LocalSettings.tsx @@ -24,6 +24,7 @@ import { VStack, Switch as HopeSwitch, IconButton, + ButtonGroup, } from "@hope-ui/solid" import { For, Match, onCleanup, Switch } from "solid-js" import { FaSolidMinus, FaSolidPlus } from "solid-icons/fa" @@ -83,7 +84,7 @@ function LocalSettingEdit(props: LocalSetting) { /> - + } @@ -100,6 +101,7 @@ function LocalSettingEdit(props: LocalSetting) { onInput={(e) => { setLocal(props.key, e.currentTarget.value) }} + borderRadius="$none" style={{ "-moz-appearance": "textfield", // @ts-ignore @@ -115,7 +117,7 @@ function LocalSettingEdit(props: LocalSetting) { setLocal(props.key, (parseInt(local[props.key]) + 1).toString()) }} /> - + diff --git a/src/store/local_settings.ts b/src/store/local_settings.ts index d02d0d915..cfa2e3407 100644 --- a/src/store/local_settings.ts +++ b/src/store/local_settings.ts @@ -71,6 +71,18 @@ export const initialLocalSettings = [ default: "14", type: "number", }, + { + key: "editor_word_wrap", + default: "false", + type: "select", + options: ["false", "true"], + }, + { + key: "editor_minimap", + default: "true", + type: "select", + options: ["false", "true"], + }, { key: "show_gallery_thumbnails", default: "visible",