|
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. |
2 | 3 | // |
3 | 4 | // 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. |
5 | 8 |
|
6 | 9 | 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" |
8 | 12 | import {oneDark} from "@codemirror/theme-one-dark" |
| 13 | +import {Tree, NodeProp, type Tree as TreeT, type TreeCursor} from "@lezer/common" |
9 | 14 | import {bsl} from "../src/index" |
10 | 15 |
|
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С: процедура с аннотацией, ветвлением, циклом |
12 | 29 | &НаКлиенте |
13 | 30 | Процедура ПриОткрытии(Отказ) |
14 | 31 | Сообщить("Привет, " + Объект.Наименование + "!"); |
@@ -38,11 +55,13 @@ const moduleSample = `// Пример модуля 1С: процедура с а |
38 | 55 |
|
39 | 56 | #КонецОбласти |
40 | 57 | ` |
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. |
46 | 65 | Функция ПолучитьКонтрагентов() Экспорт |
47 | 66 | Запрос = Новый Запрос; |
48 | 67 | Запрос.Текст = |
@@ -74,62 +93,261 @@ const querySample = `// Демонстрация встроенной грамм |
74 | 93 | Возврат Запрос.Выполнить().Выгрузить(); |
75 | 94 | КонецФункции |
76 | 95 | ` |
| 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 ----------------------------------------------------------------- |
77 | 159 |
|
78 | | -// ----- helpers -------------------------------------------------------------- |
| 160 | +const params = new URLSearchParams(location.search) |
| 161 | +let dark = params.get("theme") !== "light" |
| 162 | +let currentSampleId = params.get("sample") ?? "module" |
79 | 163 |
|
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 |
83 | 182 | } |
84 | 183 |
|
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, "&").replace(/</g, "<").replace(/>/g, ">") |
98 | 188 | } |
99 | 189 |
|
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 |
109 | 194 | } |
110 | 195 |
|
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[] = [] |
112 | 202 |
|
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 | + ) |
116 | 218 |
|
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 | + } |
123 | 226 |
|
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() |
126 | 314 |
|
127 | 315 | themeBtn.addEventListener("click", () => { |
128 | 316 | 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" |
135 | 353 | }) |
0 commit comments