Skip to content

Commit 6789b06

Browse files
nixel2007claude
andcommitted
Rework live demo: single editor + AST viewer, sample picker, drag-drop
Layout changes: - Left panel: BSL editor (was: two side-by-side editors) - Right panel: AST viewer that re-renders on every doc/selection change, descends into mounted SDBL overlays via NodeProp.mounted so query strings show their inner Query(StmtKw, MdoKw, VtKw, …) tree inline - Header: sample dropdown (Module / Query / Preprocessor / Doc-comments & labels), theme toggle, API docs link Features: - Click an AST node → editor selects the corresponding range and scrolls to it. Makes it trivial to map highlight tags to grammar nodes. - Drag-and-drop a .bsl/.os file onto the editor → loads its contents, re-renders AST, marks the sample picker with the dropped filename. - ?sample=<id> URL parameter for shareable links to specific samples; ?theme=light still flips to the light theme. Implementation note: - AST node text is escaped before insertion; cursor positions are re-based for overlay trees (MountedTree.overlay ranges are host-relative). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 73430d7 commit 6789b06

2 files changed

Lines changed: 327 additions & 77 deletions

File tree

examples/demo.ts

Lines changed: 271 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,31 @@
1-
// Live demo: two-panel CodeMirror 6 + @1c-syntax/codemirror-lang-bsl showcase.
1+
// Live demo: CodeMirror 6 editor + side-by-side AST viewer, sample picker,
2+
// and drag-and-drop file loading.
23
//
34
// Bundled to docs/bundle.js by `npm run build:demo` and deployed to GitHub
4-
// Pages by the .github/workflows/pages.yml workflow.
5+
// Pages by .github/workflows/pages.yml. The AST viewer descends into mounted
6+
// SDBL overlays so query strings show their inner Query(StmtKw, MdoKw, …)
7+
// structure inline with the surrounding BSL tree.
58

69
import {EditorView, basicSetup} from "codemirror"
7-
import {EditorState} from "@codemirror/state"
10+
import {EditorState, StateField, type Extension} from "@codemirror/state"
11+
import {syntaxTree} from "@codemirror/language"
812
import {oneDark} from "@codemirror/theme-one-dark"
13+
import {Tree, NodeProp, type Tree as TreeT, type TreeCursor} from "@lezer/common"
914
import {bsl} from "../src/index"
1015

11-
const moduleSample = `// Пример модуля 1С: процедура с аннотацией, ветвлением, циклом
16+
// ---- Samples ---------------------------------------------------------------
17+
18+
interface Sample {
19+
id: string
20+
label: string
21+
doc: string
22+
}
23+
24+
const SAMPLES: Sample[] = [
25+
{
26+
id: "module",
27+
label: "Модуль — процедура, аннотация, ветвление, цикл, async",
28+
doc: `// Пример модуля 1С: процедура с аннотацией, ветвлением, циклом
1229
&НаКлиенте
1330
Процедура ПриОткрытии(Отказ)
1431
Сообщить("Привет, " + Объект.Наименование + "!");
@@ -38,11 +55,13 @@ const moduleSample = `// Пример модуля 1С: процедура с а
3855
3956
#КонецОбласти
4057
`
41-
42-
const querySample = `// Демонстрация встроенной грамматики SDBL (язык запросов 1С) внутри строки.
43-
// Подсветка ключевых слов запроса, типов метаданных и виртуальных таблиц
44-
// активируется автоматически, когда строка начинается с ВЫБРАТЬ/SELECT.
45-
58+
},
59+
{
60+
id: "query",
61+
label: "Запрос — SDBL внутри строки, JOIN, ИТОГИ, параметры",
62+
doc: `// Демонстрация встроенной грамматики SDBL.
63+
// SDBL подсветка автоматически включается, когда строка
64+
// начинается с ВЫБРАТЬ/SELECT.
4665
Функция ПолучитьКонтрагентов() Экспорт
4766
Запрос = Новый Запрос;
4867
Запрос.Текст =
@@ -74,62 +93,261 @@ const querySample = `// Демонстрация встроенной грамм
7493
Возврат Запрос.Выполнить().Выгрузить();
7594
КонецФункции
7695
`
96+
},
97+
{
98+
id: "preproc",
99+
label: "Препроцессор — #Если / #Область / #Использовать",
100+
doc: `#Использовать "vanessa-runner"
101+
102+
#Область ПрограммныйИнтерфейс
103+
104+
#Если Клиент Тогда
105+
106+
&НаКлиенте
107+
Процедура ВыполнитьНаКлиенте() Экспорт
108+
Сообщить("работаем на клиенте");
109+
КонецПроцедуры
110+
111+
#ИначеЕсли Сервер И Не ВнешнееСоединение Тогда
112+
113+
&НаСервере
114+
Процедура ВыполнитьНаСервере() Экспорт
115+
Сообщить("работаем на сервере");
116+
КонецПроцедуры
117+
118+
#Иначе
119+
120+
Процедура НеподдерживаемоеОкружение()
121+
ВызватьИсключение "Не поддерживается";
122+
КонецПроцедуры
123+
124+
#КонецЕсли
125+
126+
#КонецОбласти
127+
`
128+
},
129+
{
130+
id: "doc",
131+
label: "Doc-комментарии (///) и метки",
132+
doc: `/// Поиск элемента в массиве по предикату.
133+
///
134+
/// @param Массив - Массив - где искать
135+
/// @param Предикат - Функция - возвращает Истина для подходящего элемента
136+
/// @returns Произвольный - первый элемент или Неопределено
137+
Функция Найти(Массив, Предикат) Экспорт
138+
Для Каждого Элемент Из Массив Цикл
139+
Если Предикат(Элемент) Тогда
140+
Возврат Элемент;
141+
КонецЕсли;
142+
КонецЦикла;
143+
Возврат Неопределено;
144+
КонецФункции
145+
146+
Процедура ОбходПоМеткам()
147+
Сч = 0;
148+
~Начало:
149+
Сч = Сч + 1;
150+
Если Сч < 5 Тогда
151+
Перейти ~Начало;
152+
КонецЕсли;
153+
КонецПроцедуры
154+
`
155+
}
156+
]
157+
158+
// ---- State -----------------------------------------------------------------
77159

78-
// ----- helpers --------------------------------------------------------------
160+
const params = new URLSearchParams(location.search)
161+
let dark = params.get("theme") !== "light"
162+
let currentSampleId = params.get("sample") ?? "module"
79163

80-
interface PanelState {
81-
view: EditorView
82-
dark: boolean
164+
const editorHost = document.getElementById("editor") as HTMLElement
165+
const astHost = document.getElementById("ast") as HTMLElement
166+
const themeBtn = document.getElementById("theme-toggle") as HTMLButtonElement
167+
const sampleSel = document.getElementById("sample") as HTMLSelectElement
168+
169+
// ---- Sample picker ---------------------------------------------------------
170+
171+
for (const s of SAMPLES) {
172+
const opt = document.createElement("option")
173+
opt.value = s.id
174+
opt.textContent = s.label
175+
sampleSel.appendChild(opt)
176+
}
177+
if (SAMPLES.some(s => s.id === currentSampleId)) {
178+
sampleSel.value = currentSampleId
179+
} else {
180+
currentSampleId = "module"
181+
sampleSel.value = currentSampleId
83182
}
84183

85-
function makePanel(parent: HTMLElement, doc: string, dark: boolean): PanelState {
86-
const view = new EditorView({
87-
state: EditorState.create({
88-
doc,
89-
extensions: [
90-
basicSetup,
91-
bsl(),
92-
...(dark ? [oneDark] : [])
93-
]
94-
}),
95-
parent
96-
})
97-
return {view, dark}
184+
// ---- AST viewer ------------------------------------------------------------
185+
186+
function escapeText(s: string): string {
187+
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
98188
}
99189

100-
function rebuild(panel: PanelState, doc: string): void {
101-
panel.view.setState(EditorState.create({
102-
doc,
103-
extensions: [
104-
basicSetup,
105-
bsl(),
106-
...(panel.dark ? [oneDark] : [])
107-
]
108-
}))
190+
function snippet(doc: string, from: number, to: number): string {
191+
const max = 40
192+
const raw = doc.slice(from, to).replace(/\n/g, "\\n").replace(/\s+/g, " ")
193+
return raw.length > max ? raw.slice(0, max) + "…" : raw
109194
}
110195

111-
// ----- bootstrap ------------------------------------------------------------
196+
// Walk a tree and render its nodes as HTML. Descends into mounted overlay
197+
// trees (the SDBL parser does this for query string literals via parseMixed)
198+
// so the AST viewer shows the inner Query(StmtKw, …) structure aligned with
199+
// the host BSL tree.
200+
function renderTree(tree: TreeT, doc: string): string {
201+
const out: string[] = []
112202

113-
const moduleHost = document.getElementById("editor-module") as HTMLElement
114-
const queryHost = document.getElementById("editor-query") as HTMLElement
115-
const themeBtn = document.getElementById("theme-toggle") as HTMLButtonElement
203+
function walk(cursor: TreeCursor, baseOffset: number, depth: number): void {
204+
do {
205+
// Cursor positions are zero-based against the *current* tree's input;
206+
// overlay trees use host-relative offsets, so we keep a running base.
207+
const from = cursor.from + baseOffset
208+
const to = cursor.to + baseOffset
209+
const text = escapeText(snippet(doc, from, to))
210+
out.push(
211+
`<div class="ast-node" data-from="${from}" data-to="${to}" ` +
212+
`style="padding-left:${depth * 14}px">` +
213+
`<span class="ast-name">${escapeText(cursor.name)}</span> ` +
214+
`<span class="ast-pos">${from}${to}</span> ` +
215+
`<span class="ast-text">${text}</span>` +
216+
`</div>`
217+
)
116218

117-
// Default to dark — One Dark contrasts highlight tags noticeably more than
118-
// CM6's default light highlightStyle, which is what people open this demo
119-
// for. A URL ?theme=light query param or the toggle below switches back.
120-
let dark = new URLSearchParams(location.search).get("theme") !== "light"
121-
themeBtn.textContent = dark ? "Светлая тема" : "Тёмная тема"
122-
document.body.classList.toggle("dark", dark)
219+
// Descend into mounted overlays (SDBL inside a BSL String node).
220+
const sub = cursor.tree
221+
const mount = sub && sub.prop(NodeProp.mounted)
222+
if (mount && mount.overlay) {
223+
const innerCursor = mount.tree.cursor()
224+
walk(innerCursor, cursor.from + baseOffset, depth + 1)
225+
}
123226

124-
const modulePanel = makePanel(moduleHost, moduleSample, dark)
125-
const queryPanel = makePanel(queryHost, querySample, dark)
227+
if (cursor.firstChild()) {
228+
walk(cursor, baseOffset, depth + 1)
229+
cursor.parent()
230+
}
231+
} while (cursor.nextSibling())
232+
}
233+
234+
walk(tree.cursor(), 0, 0)
235+
return out.join("")
236+
}
237+
238+
let suppressAstUpdate = false
239+
const astUpdateField = StateField.define<number>({
240+
create() { return 0 },
241+
update(value, tx) {
242+
if (suppressAstUpdate) return value
243+
if (tx.docChanged || tx.selection) return value + 1
244+
return value
245+
}
246+
})
247+
248+
const astUpdater = EditorView.updateListener.of(update => {
249+
if (!update.docChanged && !update.selectionSet) return
250+
const tree = syntaxTree(update.state)
251+
astHost.innerHTML = renderTree(tree, update.state.doc.toString())
252+
})
253+
254+
// ---- Editor ----------------------------------------------------------------
255+
256+
function commonExtensions(): Extension[] {
257+
return [
258+
basicSetup,
259+
bsl(),
260+
astUpdater,
261+
astUpdateField,
262+
...(dark ? [oneDark] : [])
263+
]
264+
}
265+
266+
const initial = SAMPLES.find(s => s.id === currentSampleId) ?? SAMPLES[0]
267+
let view = new EditorView({
268+
state: EditorState.create({
269+
doc: initial.doc,
270+
extensions: commonExtensions()
271+
}),
272+
parent: editorHost
273+
})
274+
275+
// Initial render (no doc-change event will fire on its own at startup).
276+
astHost.innerHTML = renderTree(syntaxTree(view.state), view.state.doc.toString())
277+
278+
// ---- Click on AST node → focus that region in the editor -------------------
279+
280+
astHost.addEventListener("click", e => {
281+
const node = (e.target as HTMLElement).closest(".ast-node") as HTMLElement | null
282+
if (!node) return
283+
const from = Number(node.dataset.from)
284+
const to = Number(node.dataset.to)
285+
view.dispatch({
286+
selection: {anchor: from, head: to},
287+
scrollIntoView: true
288+
})
289+
view.focus()
290+
})
291+
292+
// ---- Sample picker behaviour -----------------------------------------------
293+
294+
function loadSample(id: string): void {
295+
const s = SAMPLES.find(x => x.id === id)
296+
if (!s) return
297+
currentSampleId = id
298+
view.setState(EditorState.create({
299+
doc: s.doc,
300+
extensions: commonExtensions()
301+
}))
302+
astHost.innerHTML = renderTree(syntaxTree(view.state), view.state.doc.toString())
303+
}
304+
305+
sampleSel.addEventListener("change", () => loadSample(sampleSel.value))
306+
307+
// ---- Theme toggle ----------------------------------------------------------
308+
309+
function applyTheme(): void {
310+
document.body.classList.toggle("dark", dark)
311+
themeBtn.textContent = dark ? "Светлая тема" : "Тёмная тема"
312+
}
313+
applyTheme()
126314

127315
themeBtn.addEventListener("click", () => {
128316
dark = !dark
129-
modulePanel.dark = dark
130-
queryPanel.dark = dark
131-
themeBtn.textContent = dark ? "Светлая тема" : "Тёмная тема"
132-
document.body.classList.toggle("dark", dark)
133-
rebuild(modulePanel, modulePanel.view.state.doc.toString())
134-
rebuild(queryPanel, queryPanel.view.state.doc.toString())
317+
applyTheme()
318+
const doc = view.state.doc.toString()
319+
view.setState(EditorState.create({doc, extensions: commonExtensions()}))
320+
astHost.innerHTML = renderTree(syntaxTree(view.state), doc)
321+
})
322+
323+
// ---- Drag-and-drop file loading --------------------------------------------
324+
325+
;(["dragenter", "dragover"] as const).forEach(ev => {
326+
editorHost.addEventListener(ev, e => {
327+
e.preventDefault()
328+
editorHost.classList.add("drop-target")
329+
})
330+
})
331+
;(["dragleave", "drop"] as const).forEach(ev => {
332+
editorHost.addEventListener(ev, e => {
333+
e.preventDefault()
334+
editorHost.classList.remove("drop-target")
335+
})
336+
})
337+
338+
editorHost.addEventListener("drop", async e => {
339+
const file = e.dataTransfer?.files[0]
340+
if (!file) return
341+
const text = await file.text()
342+
view.setState(EditorState.create({doc: text, extensions: commonExtensions()}))
343+
astHost.innerHTML = renderTree(syntaxTree(view.state), text)
344+
// Reset the sample picker to a custom-file marker by adding/selecting one.
345+
let opt = sampleSel.querySelector<HTMLOptionElement>('option[value="__file"]')
346+
if (!opt) {
347+
opt = document.createElement("option")
348+
opt.value = "__file"
349+
sampleSel.appendChild(opt)
350+
}
351+
opt.textContent = `Загружено: ${file.name}`
352+
sampleSel.value = "__file"
135353
})

0 commit comments

Comments
 (0)