Skip to content

Commit 3bc2408

Browse files
authored
Playground: Allow to collapse/expand nodes in syntax tree (#1450)
https://github.com/user-attachments/assets/d4b590a7-d5c6-4285-9b88-82e7eabb98a2
1 parent 6700bed commit 3bc2408

File tree

4 files changed

+259
-0
lines changed

4 files changed

+259
-0
lines changed

playground/index.html

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,20 @@
511511
/>
512512
<span class="select-none">Dot-notation tags</span>
513513
</label>
514+
515+
<span class="text-gray-600 mx-1">|</span>
516+
517+
<button
518+
data-action="click->playground#expandAllNodes"
519+
class="text-gray-300 hover:text-white text-sm underline"
520+
title="Expand all tree nodes"
521+
>Expand all</button>
522+
523+
<button
524+
data-action="click->playground#collapseAllNodes"
525+
class="text-gray-300 hover:text-white text-sm underline"
526+
title="Collapse all tree nodes"
527+
>Collapse all</button>
514528
</div>
515529

516530
<pre

playground/src/controllers/playground_controller.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import Prism from "prismjs"
1010
import { Controller } from "@hotwired/stimulus"
1111
import { replaceTextareaWithMonaco } from "../monaco"
1212
import { findTreeLocationItemWithSmallestRangeFromPosition } from "../ranges"
13+
import { makeTreeCollapsible, expandAllNodes as expandAll, collapseAllNodes as collapseAll, revealTreeLine } from "../tree-collapse"
1314

1415
import { Herb } from "@herb-tools/browser"
1516
import { Linter } from "@herb-tools/linter"
@@ -174,6 +175,7 @@ export default class extends Controller {
174175
)
175176

176177
if (range) {
178+
revealTreeLine(range.element)
177179
range.element.classList.add("tree-location-highlight")
178180
range.element.scrollIntoView({
179181
behavior: "smooth",
@@ -645,6 +647,18 @@ export default class extends Controller {
645647
element.classList.add("hover-highlight")
646648
}
647649

650+
expandAllNodes() {
651+
if (this.hasParseOutputTarget) {
652+
expandAll(this.parseOutputTarget)
653+
}
654+
}
655+
656+
collapseAllNodes() {
657+
if (this.hasParseOutputTarget) {
658+
collapseAll(this.parseOutputTarget)
659+
}
660+
}
661+
648662
clearTreeLocationHighlights() {
649663
this.parseOutputTarget
650664
.querySelectorAll(".tree-location-highlight")
@@ -901,6 +915,7 @@ export default class extends Controller {
901915
this.parseOutputTarget.textContent = result.string
902916

903917
Prism.highlightElement(this.parseOutputTarget)
918+
makeTreeCollapsible(this.parseOutputTarget)
904919

905920
this.treeLocations.forEach(({ element, locationElement, location }) => {
906921
this.setupHoverListener(locationElement, location)
@@ -1137,6 +1152,7 @@ export default class extends Controller {
11371152
this.parseOutputTarget.textContent = result.string
11381153

11391154
Prism.highlightElement(this.parseOutputTarget)
1155+
makeTreeCollapsible(this.parseOutputTarget)
11401156

11411157
this.treeLocations.forEach(({ element, locationElement, location }) => {
11421158
this.setupHoverListener(locationElement, location)

playground/src/style.css

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,49 @@ code.language-tree {
8585
white-space: pre;
8686
}
8787

88+
.tree-line {
89+
display: block;
90+
position: relative;
91+
}
92+
93+
.tree-toggle {
94+
display: none;
95+
}
96+
97+
.tree-collapsible .token.node,
98+
.tree-collapsible .token.error-class {
99+
cursor: pointer;
100+
font-size: 0;
101+
}
102+
103+
.tree-collapsible .token.node::before,
104+
.tree-collapsible .token.error-class::before {
105+
content: attr(data-prefix) " " attr(data-name);
106+
font-size: 14px;
107+
}
108+
109+
.tree-collapsible:not([data-collapsed="true"]) .token.node:hover::before,
110+
.tree-collapsible:not([data-collapsed="true"]) .token.error-class:hover::before {
111+
content: "- " attr(data-name);
112+
}
113+
114+
.tree-collapsible[data-collapsed="true"] .token.node:hover::before,
115+
.tree-collapsible[data-collapsed="true"] .token.error-class:hover::before {
116+
content: "+ " attr(data-name);
117+
}
118+
119+
120+
.tree-collapsible[data-collapsed="true"] .token.node::after,
121+
.tree-collapsible[data-collapsed="true"] .token.error-class::after {
122+
content: " \2026";
123+
color: #607d8b;
124+
font-style: italic;
125+
}
126+
127+
.tree-collapsible[data-collapsed="true"] {
128+
opacity: 0.25;
129+
}
130+
88131
#monaco-input-container {
89132
width: 100%;
90133
height: 100% !important;

playground/src/tree-collapse.js

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
export function makeTreeCollapsible(element) {
2+
const html = element.innerHTML
3+
const lines = html.split("\n")
4+
5+
const processedLines = lines.map((lineHtml, index) => {
6+
const tempElement = document.createElement("span")
7+
tempElement.innerHTML = lineHtml
8+
const plainText = tempElement.textContent || ""
9+
const depth = getLineDepth(plainText)
10+
const isNodeLine = lineHtml.includes('class="token node"') || lineHtml.includes('class="token error-class"')
11+
12+
const toggleHtml = isNodeLine
13+
? '<span class="tree-toggle" data-collapsed="false"></span>'
14+
: ""
15+
16+
const classes = ["tree-line"]
17+
if (isNodeLine) classes.push("tree-collapsible")
18+
19+
return `<span class="${classes.join(" ")}" data-depth="${depth}" data-line-index="${index}">${toggleHtml}${lineHtml}</span>`
20+
})
21+
22+
element.innerHTML = processedLines.join("")
23+
24+
element.querySelectorAll(".tree-toggle").forEach((toggle) => {
25+
toggle.addEventListener("click", (event) => {
26+
event.preventDefault()
27+
event.stopPropagation()
28+
toggleTreeNode(toggle)
29+
})
30+
})
31+
32+
element.querySelectorAll(".tree-collapsible .token.node, .tree-collapsible .token.error-class").forEach((token) => {
33+
const name = token.textContent.replace(/^@ /, "")
34+
token.dataset.name = name
35+
token.dataset.prefix = "@"
36+
37+
token.addEventListener("click", (event) => {
38+
event.preventDefault()
39+
event.stopPropagation()
40+
const toggle = token.closest(".tree-collapsible").querySelector(".tree-toggle")
41+
if (toggle) toggleTreeNode(toggle)
42+
})
43+
})
44+
}
45+
46+
export function expandAllNodes(element) {
47+
element.querySelectorAll(".tree-toggle").forEach((toggle) => {
48+
if (toggle.dataset.collapsed === "true") {
49+
toggleTreeNode(toggle)
50+
}
51+
})
52+
}
53+
54+
export function collapseAllNodes(element) {
55+
const toggles = Array.from(element.querySelectorAll(".tree-toggle"))
56+
57+
toggles.sort((a, b) => {
58+
const depthA = parseInt(a.closest(".tree-line").dataset.depth)
59+
const depthB = parseInt(b.closest(".tree-line").dataset.depth)
60+
return depthB - depthA
61+
})
62+
63+
toggles.forEach((toggle) => {
64+
if (toggle.dataset.collapsed === "false") {
65+
toggleTreeNode(toggle)
66+
}
67+
})
68+
69+
const rootToggle = toggles.find((toggle) => {
70+
return parseInt(toggle.closest(".tree-line").dataset.depth) === 0
71+
})
72+
73+
if (rootToggle && rootToggle.dataset.collapsed === "true") {
74+
toggleTreeNode(rootToggle)
75+
}
76+
}
77+
78+
export function revealTreeLine(element) {
79+
if (!element) return
80+
81+
const line = element.closest(".tree-line")
82+
if (!line) return
83+
84+
const depth = parseInt(line.dataset.depth)
85+
86+
let current = line.previousElementSibling
87+
let currentDepth = depth
88+
89+
while (current) {
90+
const siblingDepth = parseInt(current.dataset.depth)
91+
92+
if (siblingDepth < currentDepth && current.classList.contains("tree-collapsible")) {
93+
const toggle = current.querySelector(".tree-toggle")
94+
if (toggle && toggle.dataset.collapsed === "true") {
95+
toggleTreeNode(toggle)
96+
}
97+
currentDepth = siblingDepth
98+
}
99+
100+
if (siblingDepth === 0) break
101+
current = current.previousElementSibling
102+
}
103+
}
104+
105+
function getLineDepth(plainText) {
106+
const match = plainText.match(/[A-Za-z@]/)
107+
if (!match) return 9999
108+
return Math.floor(match.index / 4)
109+
}
110+
111+
function isBlankTreeLine(treeLine) {
112+
const plainText = treeLine.textContent.replace(/[\u25BC\u25B6]/g, "")
113+
return plainText.trim() === "" || /^[\s]*$/.test(plainText)
114+
}
115+
116+
function toggleTreeNode(toggle) {
117+
const line = toggle.closest(".tree-line")
118+
const depth = parseInt(line.dataset.depth)
119+
const isCollapsed = toggle.dataset.collapsed === "true"
120+
121+
const children = []
122+
let sibling = line.nextElementSibling
123+
124+
while (sibling) {
125+
const siblingDepth = parseInt(sibling.dataset.depth)
126+
if (siblingDepth <= depth) break
127+
children.push(sibling)
128+
sibling = sibling.nextElementSibling
129+
}
130+
131+
if (isCollapsed) {
132+
for (let index = 0; index < children.length; index++) {
133+
const child = children[index]
134+
child.style.display = ""
135+
136+
if (child.classList.contains("tree-collapsible")) {
137+
const subToggle = child.querySelector(".tree-toggle")
138+
139+
if (subToggle && subToggle.dataset.collapsed === "true") {
140+
const subDepth = parseInt(child.dataset.depth)
141+
index++
142+
143+
while (index < children.length && parseInt(children[index].dataset.depth) > subDepth) {
144+
const nextIndex = index + 1
145+
146+
if (nextIndex < children.length && parseInt(children[nextIndex].dataset.depth) <= subDepth && isBlankTreeLine(children[index])) {
147+
children[index].style.display = ""
148+
}
149+
150+
index++
151+
}
152+
153+
index--
154+
}
155+
}
156+
}
157+
} else {
158+
let lastVisibleBlank = -1
159+
160+
for (let index = children.length - 1; index >= 0; index--) {
161+
if (isBlankTreeLine(children[index])) {
162+
lastVisibleBlank = index
163+
break
164+
}
165+
166+
break
167+
}
168+
169+
for (let index = 0; index < children.length; index++) {
170+
if (index === lastVisibleBlank) {
171+
children[index].style.display = ""
172+
} else {
173+
children[index].style.display = "none"
174+
}
175+
}
176+
}
177+
178+
toggle.dataset.collapsed = isCollapsed ? "false" : "true"
179+
line.dataset.collapsed = toggle.dataset.collapsed
180+
181+
const nodeToken = line.querySelector(".token.node, .token.error-class")
182+
183+
if (nodeToken) {
184+
nodeToken.dataset.prefix = toggle.dataset.collapsed === "true" ? "-" : "@"
185+
}
186+
}

0 commit comments

Comments
 (0)