Skip to content

Commit b4ad13b

Browse files
committed
Playground 源码编辑器加上语法高亮与行号(零依赖:透明 textarea 叠高亮层)
1 parent 09d3612 commit b4ad13b

1 file changed

Lines changed: 108 additions & 8 deletions

File tree

.vitepress/theme/MiniVMPlayground.vue

Lines changed: 108 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup>
2-
import { ref, nextTick } from 'vue'
2+
import { ref, computed, nextTick } from 'vue'
33
import TOY_SRC from '../../practice/mini-vm/minivm.py?raw'
44
import { getPyodide } from './pyodide'
55
@@ -60,6 +60,78 @@ const inputRef = ref(null)
6060
let cmdHistory = []
6161
let histIdx = 0
6262
63+
// 左侧源码编辑器:语法高亮 + 行号(零依赖:透明 textarea 叠在高亮层之上)
64+
const srcRef = ref(null)
65+
const hlRef = ref(null)
66+
const gutterInnerRef = ref(null)
67+
const lineCount = computed(() => vmSource.value.split('\n').length)
68+
const highlighted = computed(() => highlightPy(vmSource.value))
69+
70+
const PY_KW = new Set(['def', 'return', 'if', 'elif', 'else', 'while', 'for', 'in', 'and', 'or',
71+
'not', 'is', 'class', 'import', 'from', 'as', 'pass', 'break', 'continue', 'True', 'False',
72+
'None', 'global', 'nonlocal', 'lambda', 'with', 'try', 'except', 'finally', 'raise', 'yield',
73+
'assert', 'del'])
74+
const PY_BUILTIN = new Set(['print', 'len', 'range', 'isinstance', 'dict', 'list', 'set', 'tuple',
75+
'int', 'float', 'str', 'bool', 'zip', 'enumerate', 'abs', 'min', 'max', 'sum', 'type', 'repr',
76+
'sorted', 'map', 'filter', 'open', 'id', 'hasattr', 'getattr', 'setattr'])
77+
78+
function escHtml(s) { return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') }
79+
80+
function highlightPy(src) {
81+
let i = 0; const n = src.length; let out = ''
82+
while (i < n) {
83+
const c = src[i]
84+
if (c === '#') { // 注释
85+
let j = i; while (j < n && src[j] !== '\n') j++
86+
out += '<span class="tok-com">' + escHtml(src.slice(i, j)) + '</span>'; i = j; continue
87+
}
88+
if (src.startsWith('"""', i) || src.startsWith("'''", i)) { // 三引号字符串
89+
const q = src.substr(i, 3); let j = i + 3
90+
while (j < n && !src.startsWith(q, j)) j++
91+
j = Math.min(n, j + 3)
92+
out += '<span class="tok-str">' + escHtml(src.slice(i, j)) + '</span>'; i = j; continue
93+
}
94+
if (c === '"' || c === "'") { // 普通字符串
95+
let j = i + 1
96+
while (j < n && src[j] !== c) { if (src[j] === '\\') j++; j++ }
97+
j = Math.min(n, j + 1)
98+
out += '<span class="tok-str">' + escHtml(src.slice(i, j)) + '</span>'; i = j; continue
99+
}
100+
if (c >= '0' && c <= '9') { // 数字
101+
let j = i; while (j < n && /[0-9._a-fA-FxXoObB]/.test(src[j])) j++
102+
out += '<span class="tok-num">' + escHtml(src.slice(i, j)) + '</span>'; i = j; continue
103+
}
104+
if (/[A-Za-z_]/.test(c)) { // 标识符 / 关键字 / 内建
105+
let j = i; while (j < n && /[A-Za-z0-9_]/.test(src[j])) j++
106+
const w = src.slice(i, j)
107+
out += PY_KW.has(w) ? '<span class="tok-kw">' + w + '</span>'
108+
: PY_BUILTIN.has(w) ? '<span class="tok-builtin">' + w + '</span>'
109+
: escHtml(w)
110+
i = j; continue
111+
}
112+
out += escHtml(c); i++
113+
}
114+
return out
115+
}
116+
117+
function onSrcScroll() {
118+
const ta = srcRef.value
119+
if (!ta) return
120+
if (hlRef.value) { hlRef.value.scrollTop = ta.scrollTop; hlRef.value.scrollLeft = ta.scrollLeft }
121+
if (gutterInnerRef.value) gutterInnerRef.value.style.transform = 'translateY(' + (-ta.scrollTop) + 'px)'
122+
}
123+
124+
function onSrcKey(e) {
125+
if (e.key === 'Tab') { // Tab 缩进 4 空格
126+
e.preventDefault()
127+
const ta = srcRef.value
128+
const s = ta.selectionStart, en = ta.selectionEnd
129+
const v = vmSource.value
130+
vmSource.value = v.slice(0, s) + ' ' + v.slice(en)
131+
nextTick(() => { ta.selectionStart = ta.selectionEnd = s + 4 })
132+
}
133+
}
134+
63135
async function ensureDriver() {
64136
if (py.value) return py.value
65137
status.value = 'loading'
@@ -182,7 +254,19 @@ async function scrollDown() {
182254
{{ status === 'loading' ? '加载中…' : '应用并重载 VM' }}
183255
</button>
184256
</div>
185-
<textarea v-model="vmSource" class="pg-src" spellcheck="false"></textarea>
257+
<div class="pg-editor">
258+
<div class="pg-gutter">
259+
<div ref="gutterInnerRef" class="pg-gutter-inner">
260+
<div v-for="n in lineCount" :key="n">{{ n }}</div>
261+
</div>
262+
</div>
263+
<div class="pg-codewrap">
264+
<pre ref="hlRef" class="pg-hl" aria-hidden="true"><code v-html="highlighted"></code></pre>
265+
<textarea ref="srcRef" v-model="vmSource" class="pg-ta" spellcheck="false"
266+
wrap="off" autocapitalize="off" autocomplete="off"
267+
@scroll="onSrcScroll" @keydown="onSrcKey"></textarea>
268+
</div>
269+
</div>
186270
<div v-if="vmError" class="pg-error">⚠ {{ vmError }}</div>
187271
</div>
188272
@@ -238,12 +322,28 @@ async function scrollDown() {
238322
.pg-btn:disabled { opacity: 0.45; cursor: not-allowed; }
239323
.pg-apply { background: var(--vp-c-brand-1); color: #fff; border-color: var(--vp-c-brand-1); font-weight: 600; }
240324
.pg-apply:hover:not(:disabled) { background: var(--vp-c-brand-2); color: #fff; }
241-
.pg-src {
242-
width: 100%; box-sizing: border-box; height: 420px; resize: vertical;
243-
font-family: var(--vp-font-family-mono, monospace); font-size: 12px; line-height: 1.5;
244-
padding: 10px; border-radius: 8px; border: 1px solid var(--vp-c-divider);
245-
background: var(--vp-c-bg); color: var(--vp-c-text-1); white-space: pre; overflow: auto;
325+
.pg-editor {
326+
display: flex; height: 420px; box-sizing: border-box;
327+
border: 1px solid var(--vp-c-divider); border-radius: 8px; overflow: hidden;
328+
background: var(--vp-c-bg);
329+
font-family: var(--vp-font-family-mono, monospace); font-size: 12px; line-height: 1.6;
330+
}
331+
.pg-gutter { flex: none; width: 46px; overflow: hidden; background: var(--vp-c-bg-soft); border-right: 1px solid var(--vp-c-divider); }
332+
.pg-gutter-inner { padding: 10px 8px 10px 0; text-align: right; color: var(--vp-c-text-3); user-select: none; }
333+
.pg-codewrap { position: relative; flex: 1; min-width: 0; }
334+
.pg-hl, .pg-ta {
335+
position: absolute; inset: 0; margin: 0; box-sizing: border-box; border: 0;
336+
padding: 10px 12px; font-family: inherit; font-size: inherit; line-height: inherit;
337+
white-space: pre; tab-size: 4; overflow: auto;
246338
}
339+
.pg-hl { z-index: 0; pointer-events: none; color: var(--vp-c-text-1); }
340+
.pg-hl code { font: inherit; padding: 0; background: none; }
341+
.pg-ta { z-index: 1; background: transparent; color: transparent; caret-color: var(--vp-c-brand-1); resize: none; outline: none; }
342+
.tok-kw { color: var(--vp-c-purple-1); }
343+
.tok-str { color: var(--vp-c-green-1); }
344+
.tok-com { color: var(--vp-c-text-3); font-style: italic; }
345+
.tok-num { color: var(--vp-c-yellow-2); }
346+
.tok-builtin { color: var(--vp-c-indigo-1); }
247347
.pg-term {
248348
height: 420px; box-sizing: border-box; overflow: auto; cursor: text;
249349
padding: 10px 12px; border-radius: 8px; border: 1px solid var(--vp-c-divider);
@@ -275,6 +375,6 @@ async function scrollDown() {
275375
.pg-tip code, .pg-muted code { font-family: var(--vp-font-family-mono, monospace); color: var(--vp-c-brand-1); }
276376
@media (max-width: 720px) {
277377
.pg-cols { grid-template-columns: 1fr; }
278-
.pg-src, .pg-term { height: 280px; }
378+
.pg-editor, .pg-term { height: 280px; }
279379
}
280380
</style>

0 commit comments

Comments
 (0)