Skip to content

Commit e6a8755

Browse files
committed
Feat(core): Fast-Form supports control CodeEditor
1 parent f1a214e commit e6a8755

5 files changed

Lines changed: 216 additions & 95 deletions

File tree

plugin/global/core/components/fast-form.js

Lines changed: 94 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -402,7 +402,7 @@ class FastForm extends HTMLElement {
402402
const state = this.states.init(name, initialState)
403403
if (typeof clear !== "function") {
404404
clear = (state && typeof state.values === "function")
405-
? () => Array.from(state.values()).forEach(val => val && typeof val.clear === "function" && val.clear())
405+
? () => Array.from(state.values()).forEach(s => s && typeof s.clear === "function" && s.clear())
406406
: () => void 0
407407
}
408408
this.registerCleanup(() => clear(state))
@@ -1449,74 +1449,30 @@ const Feature_Watchers = (() => {
14491449
}
14501450
})()
14511451

1452-
function compileMatchers({ source, strategy, processValue, errorContext }) {
1453-
if (!source || typeof source !== "object") {
1454-
return []
1455-
}
1456-
const escapeRegex = (string) => string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
1457-
return Object.entries(source).map(([key, rawValue]) => {
1458-
const payload = processValue(rawValue, key)
1459-
if (payload == null) return
1460-
try {
1461-
const exp = (strategy === "regex")
1462-
? key
1463-
: (strategy === "wildcard")
1464-
? `^${escapeRegex(key).replace(/\\\*/g, ".*")}$`
1465-
: `^${escapeRegex(key)}$`
1466-
const regex = new RegExp(exp)
1467-
return { key, regex, ...payload }
1468-
} catch (e) {
1469-
throw new TypeError(`Invalid ${errorContext} pattern for '${strategy}' mode: '${key}'.`)
1470-
}
1471-
}).filter(Boolean)
1472-
}
1473-
14741452
const Feature_Parsing = {
14751453
featureOptions: {
14761454
parsers: {},
1477-
parserMatchStrategy: "exact", // "exact", "wildcard", "regex"
14781455
},
14791456
configure: ({ hooks, initState, registerApi }) => {
1480-
const state = initState(
1481-
{ rawParsers: new Map(), compiledParsers: [] },
1482-
state => {
1483-
state.rawParsers.clear()
1484-
state.compiledParsers = []
1485-
},
1486-
)
1457+
const parsers = initState(new Map(), s => s.clear())
14871458
registerApi("parsing", {
1488-
setParser: (key, parserToAdd) => {
1459+
set: (key, parserToAdd) => {
14891460
if (key && typeof parserToAdd === "function") {
1490-
state.rawParsers.set(key, parserToAdd)
1461+
parsers.set(key, parserToAdd)
14911462
} else {
14921463
console.warn(`FastForm Warning: Parser for key '${key}' is not a function.`)
14931464
}
1494-
}
1465+
},
1466+
get: (key) => parsers.get(key),
14951467
})
14961468
hooks.on("onProcessValue", (value, changeContext) => {
1497-
const newChangeContext = { ...changeContext, value }
1498-
const matchedParsers = state.compiledParsers.filter(p => p.regex.test(newChangeContext.key))
1499-
if (matchedParsers.length === 0) {
1500-
return value
1501-
}
1502-
if (matchedParsers.length > 1) {
1503-
matchedParsers.sort((a, b) => b.key.length - a.key.length)
1504-
}
1505-
return matchedParsers[0].parser(value, newChangeContext)
1469+
const parser = parsers.get(changeContext.key)
1470+
return parser ? parser(value, changeContext) : value
15061471
})
15071472
},
1508-
compile: ({ state, options, form }) => {
1509-
const { parsers, parserMatchStrategy } = options
1510-
1473+
compile: ({ options, form }) => {
15111474
const api = form.getApi("parsing")
1512-
Object.entries(parsers).forEach(([key, rule]) => api.setParser(key, rule))
1513-
1514-
state.compiledParsers = compileMatchers({
1515-
source: Object.fromEntries(state.rawParsers),
1516-
strategy: parserMatchStrategy,
1517-
errorContext: "parser",
1518-
processValue: (parser) => ({ parser })
1519-
})
1475+
Object.entries(options.parsers).forEach(([key, rule]) => api.set(key, rule))
15201476
},
15211477
}
15221478

@@ -1580,13 +1536,7 @@ const Feature_Validation = {
15801536
}).filter(Boolean)
15811537
},
15821538
configure: ({ initState, hooks, registerApi, form }) => {
1583-
const state = initState(
1584-
{ rawRules: new Map(), compiledRules: new Map() },
1585-
state => {
1586-
state.rawRules.clear()
1587-
state.compiledRules.clear()
1588-
}
1589-
)
1539+
const state = initState({ rawRules: new Map(), compiledRules: new Map() })
15901540
registerApi("validation", {
15911541
addRule: (key, ruleConfig) => {
15921542
if (!key || !ruleConfig) return
@@ -2222,7 +2172,7 @@ const Control_Color = {
22222172
const input = element.querySelector(".color-input")
22232173
const display = element.querySelector(".color-display")
22242174
if (input && display) {
2225-
value = value || "#000000"
2175+
value = value || "#FFFFFF"
22262176
updateInputState(input, field, value)
22272177
display.textContent = value.toUpperCase()
22282178
}
@@ -2507,6 +2457,86 @@ const Control_Textarea = {
25072457
},
25082458
}
25092459

2460+
const Control_CodeEditor = {
2461+
controlOptions: {
2462+
tabSize: 4,
2463+
lineNumbers: true,
2464+
},
2465+
setup: ({ field }) => defaultBlockLayout(field),
2466+
create: ({ field, controlOptions }) => {
2467+
const { lineNumbers } = controlOptions
2468+
const { key, placeholder } = getCommonHTMLAttrs(field)
2469+
const gutterClass = lineNumbers ? "code-gutter" : "code-gutter plugin-common-hidden"
2470+
const textarea = `<textarea class="code-textarea" ${placeholder} spellcheck="false" autocomplete="off" autocapitalize="off"></textarea>`
2471+
return `<div class="code-editor-wrap" ${key}><div class="${gutterClass}"></div><div class="code-grow-wrap"><div class="code-ghost"></div>${textarea}</div></div>`
2472+
},
2473+
update: ({ element, value, field, controlOptions }) => {
2474+
const textarea = element.querySelector(".code-textarea")
2475+
if (!textarea) return
2476+
const ghost = element.querySelector(".code-ghost")
2477+
const gutter = element.querySelector(".code-gutter")
2478+
const val = value || ""
2479+
if (textarea.value !== val) textarea.value = val
2480+
if (ghost) ghost.textContent = val + "\n"
2481+
updateInputState(textarea, field, val)
2482+
if (controlOptions.lineNumbers && gutter) {
2483+
Control_CodeEditor._updateLineNumbers(textarea, gutter)
2484+
}
2485+
},
2486+
bindEvents: ({ form }) => {
2487+
const syncState = (textarea) => {
2488+
const wrap = textarea.closest(".code-editor-wrap")
2489+
const ghost = wrap.querySelector(".code-ghost")
2490+
const gutter = wrap.querySelector(".code-gutter")
2491+
ghost.textContent = textarea.value + "\n"
2492+
if (gutter && !gutter.classList.contains("plugin-common-hidden")) {
2493+
Control_CodeEditor._updateLineNumbers(textarea, gutter)
2494+
}
2495+
}
2496+
2497+
form.onEvent("input", ".code-textarea", function () {
2498+
syncState(this)
2499+
}).onEvent("change", ".code-textarea", function () {
2500+
form.validateAndCommit(this.closest(".code-editor-wrap").dataset.key, this.value)
2501+
}).onEvent("scroll", ".code-textarea", function () {
2502+
const gutter = this.closest(".code-editor-wrap").querySelector(".code-gutter")
2503+
if (gutter) gutter.scrollTop = this.scrollTop
2504+
}).onEvent("keydown", ".code-textarea", function (ev) {
2505+
const key = this.closest(".code-editor-wrap").dataset.key
2506+
const { tabSize } = form.getControlOptionsFromKey(key)
2507+
if (ev.key === "Tab") {
2508+
ev.preventDefault()
2509+
const spaces = " ".repeat(tabSize)
2510+
Control_CodeEditor._insertText(this, spaces)
2511+
syncState(this)
2512+
form.validateAndCommit(key, this.value)
2513+
} else if (ev.key === "Enter") {
2514+
ev.preventDefault()
2515+
const cursor = this.selectionStart
2516+
const currentLineStart = this.value.lastIndexOf("\n", cursor - 1) + 1
2517+
const currentLine = this.value.substring(currentLineStart, cursor)
2518+
const match = currentLine.match(/^\s+/)
2519+
const indentation = match ? match[0] : ""
2520+
Control_CodeEditor._insertText(this, "\n" + indentation)
2521+
syncState(this)
2522+
this.blur()
2523+
this.focus()
2524+
form.validateAndCommit(key, this.value)
2525+
}
2526+
}, true)
2527+
},
2528+
_updateLineNumbers: (textarea, gutter) => {
2529+
const lineCount = textarea.value.split("\n").length
2530+
if (gutter.childElementCount === lineCount) return
2531+
gutter.innerHTML = Array.from({ length: lineCount }, (_, i) => `<div>${i + 1}</div>`).join("")
2532+
},
2533+
_insertText: (textarea, text) => {
2534+
const start = textarea.selectionStart
2535+
const end = textarea.selectionEnd
2536+
textarea.setRangeText(text, start, end, "end")
2537+
},
2538+
}
2539+
25102540
const Control_Object = {
25112541
controlOptions: {
25122542
format: "JSON",
@@ -3114,11 +3144,7 @@ const Control_Transfer = {
31143144
item.animate([
31153145
{ transform: `translate(${dx}px, ${dy}px)` },
31163146
{ transform: "translate(0, 0)" }
3117-
], {
3118-
duration: 200,
3119-
easing: "cubic-bezier(0.2, 0, 0, 1)",
3120-
fill: "both"
3121-
})
3147+
], { duration: 200, easing: "cubic-bezier(0.2, 0, 0, 1)", fill: "both" })
31223148
}
31233149
})
31243150
},
@@ -3632,6 +3658,7 @@ FastForm.registerControl("custom", Control_Custom)
36323658
FastForm.registerControl("hint", Control_Hint)
36333659
FastForm.registerControl("hotkey", Control_Hotkey)
36343660
FastForm.registerControl("textarea", Control_Textarea)
3661+
FastForm.registerControl("code", Control_CodeEditor)
36353662
FastForm.registerControl("object", Control_Object)
36363663
FastForm.registerControl("array", Control_Array)
36373664
FastForm.registerControl("select", Control_Select)

plugin/global/settings/settings.default.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -989,6 +989,7 @@ Note that if blocks and lists appear at the same level, the lists will be ignore
989989
|-|-|
990990
| Apple | 4 |
991991
| Banana | 2 |
992+
```
992993
"""
993994

994995

plugin/global/styles/plugin-fast-form.css

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,87 @@ textarea:disabled {
729729
}
730730
}
731731

732+
/* Code */
733+
.code-editor-wrap {
734+
display: flex;
735+
position: relative;
736+
width: 100%;
737+
border: 1px solid var(--ff-border);
738+
border-radius: var(--ff-radius);
739+
background-color: var(--ff-bg-white);
740+
transition: border-color 0.2s, box-shadow 0.2s;
741+
font-family: "Menlo", "Monaco", "Consolas", "Courier New", monospace;
742+
font-size: 13px;
743+
line-height: 1.5;
744+
align-items: stretch;
745+
min-width: 0;
746+
}
747+
748+
.code-editor-wrap:focus-within {
749+
border-color: var(--ff-primary);
750+
box-shadow: 0 0 0 2px var(--ff-focus-ring);
751+
}
752+
753+
.code-gutter {
754+
flex-shrink: 0;
755+
min-width: 36px;
756+
background-color: var(--ff-bg-block);
757+
border-right: 1px solid var(--ff-border);
758+
color: var(--ff-text-placeholder);
759+
text-align: right;
760+
padding: 8px 6px;
761+
user-select: none;
762+
box-sizing: border-box;
763+
overflow: hidden;
764+
}
765+
766+
.code-grow-wrap {
767+
flex: 1;
768+
display: grid;
769+
grid-template-columns: 100%;
770+
margin: 0;
771+
padding: 8px;
772+
min-height: 100px;
773+
min-width: 0;
774+
}
775+
776+
.code-ghost, .code-textarea {
777+
grid-area: 1 / 1 / 2 / 2;
778+
font: inherit;
779+
padding: 0;
780+
margin: 0;
781+
box-sizing: border-box;
782+
white-space: pre;
783+
word-break: break-all;
784+
overflow-wrap: break-word;
785+
line-height: inherit;
786+
}
787+
788+
.code-ghost {
789+
visibility: hidden;
790+
pointer-events: none;
791+
width: 0;
792+
overflow: hidden;
793+
padding-bottom: 1.2em;
794+
}
795+
796+
.code-textarea {
797+
display: block;
798+
border: 0;
799+
outline: none;
800+
background: transparent;
801+
resize: none;
802+
color: var(--ff-text-main);
803+
overflow-y: hidden;
804+
overflow-x: auto;
805+
width: 100%;
806+
}
807+
808+
.code-textarea:focus {
809+
box-shadow: none;
810+
border-color: transparent;
811+
}
812+
732813
/* Object */
733814
.object-wrap {
734815
display: flex;

plugin/preferences/actions.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ module.exports = (plugin) => {
4242
},
4343
invokeMarkdownLintSettings: async () => utils.callPluginFunction("markdownLint", "settings"),
4444
installPlantUMLServer: async () => {
45-
const dockerFields = [{ key: "dockerCommand", type: "textarea", readonly: true, rows: 3 }]
45+
const dockerFields = [{ key: "dockerCommand", type: "code", readonly: true }]
4646
const actionFields = [
4747
{ key: "viewWebsite", type: "action", label: "Official Website" },
4848
{ key: "viewDockerHub", type: "action", label: "Docker Hub" },
@@ -79,7 +79,7 @@ module.exports = (plugin) => {
7979
const settings = await plugin._getSettings(fixedName)
8080
const op = {
8181
title: i18n._t("settings", "$label.runtimeSettings") + `(${i18n.t("readonly")})`,
82-
schema: [{ fields: [{ key: "runtimeSettings", type: "textarea", readonly: true, rows: 14 }] }],
82+
schema: [{ fields: [{ key: "runtimeSettings", type: "code", readonly: true }] }],
8383
data: { runtimeSettings: JSON.stringify(settings, null, "\t") },
8484
}
8585
await utils.formDialog.modal(op)

0 commit comments

Comments
 (0)