mirror of
https://github.com/leporello-js/leporello-js
synced 2026-01-14 05:14:28 -08:00
initial
This commit is contained in:
173
src/editor/calltree.js
Normal file
173
src/editor/calltree.js
Normal file
@@ -0,0 +1,173 @@
|
||||
import {exec} from '../index.js'
|
||||
import {el, stringify, fn_link, scrollIntoViewIfNeeded} from './domutils.js'
|
||||
import {FLAGS} from '../feature_flags.js'
|
||||
import {stringify_for_header} from './value_explorer.js'
|
||||
import {find_node} from '../ast_utils.js'
|
||||
import {is_expandable, root_calltree_node} from '../calltree.js'
|
||||
|
||||
// TODO perf - quadratic difficulty
|
||||
const join = arr => arr.reduce(
|
||||
(acc, el) => acc.length == 0
|
||||
? [el]
|
||||
: [...acc, ',', el],
|
||||
[],
|
||||
)
|
||||
|
||||
export class CallTree {
|
||||
constructor(ui, container) {
|
||||
this.ui = ui
|
||||
this.container = container
|
||||
|
||||
this.container.addEventListener('keydown', (e) => {
|
||||
|
||||
// Do not scroll
|
||||
e.preventDefault()
|
||||
|
||||
if(e.key == 'F1') {
|
||||
this.ui.editor.focus()
|
||||
}
|
||||
|
||||
if(e.key == 'F2') {
|
||||
this.ui.editor.focus_value_explorer(this.container)
|
||||
}
|
||||
|
||||
if(e.key == 'a') {
|
||||
if(FLAGS.embed_value_explorer) {
|
||||
exec('calltree.select_arguments')
|
||||
} else {
|
||||
// TODO make clear that arguments are shown
|
||||
this.ui.eval.show_value(this.state.current_calltree_node.args)
|
||||
this.ui.eval.focus_value_or_error(this.container)
|
||||
}
|
||||
}
|
||||
|
||||
if(e.key == 'r' || e.key == 'Enter') {
|
||||
if(FLAGS.embed_value_explorer) {
|
||||
exec('calltree.select_return_value')
|
||||
} else {
|
||||
// TODO make clear that return value is shown
|
||||
this.ui.eval.show_value_or_error(this.state.current_calltree_node)
|
||||
this.ui.eval.focus_value_or_error(this.container)
|
||||
}
|
||||
}
|
||||
|
||||
if(e.key == 'ArrowDown' || e.key == 'j'){
|
||||
exec('calltree.arrow_down')
|
||||
}
|
||||
|
||||
if(e.key == 'ArrowUp' || e.key == 'k'){
|
||||
exec('calltree.arrow_up')
|
||||
}
|
||||
|
||||
if(e.key == 'ArrowLeft' || e.key == 'h'){
|
||||
exec('calltree.arrow_left')
|
||||
}
|
||||
|
||||
if(e.key == 'ArrowRight' || e.key == 'l'){
|
||||
exec('calltree.arrow_right')
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
on_click_node(id) {
|
||||
exec('calltree.click', id)
|
||||
}
|
||||
|
||||
clear_calltree(){
|
||||
this.container.innerHTML = ''
|
||||
this.node_to_el = new Map()
|
||||
this.state = null
|
||||
}
|
||||
|
||||
render_node(n, current_node){
|
||||
const is_expanded = this.state.calltree_node_is_expanded[n.id]
|
||||
|
||||
const result = el('div', 'callnode',
|
||||
el('div', {
|
||||
'class': (n == current_node ? 'call_el active' : 'call_el'),
|
||||
click: () => this.on_click_node(n.id),
|
||||
},
|
||||
!is_expandable(n)
|
||||
? '\xa0'
|
||||
: is_expanded ? '▼' : '▶',
|
||||
n.toplevel
|
||||
? el('span', '',
|
||||
el('i', '',
|
||||
'toplevel: ' + (n.module == '' ? '*scratch*' : n.module),
|
||||
),
|
||||
n.ok ? '' : el('span', 'call_header error', '\xa0', n.error.toString()),
|
||||
)
|
||||
: el('span',
|
||||
'call_header '
|
||||
+ (n.ok ? '' : 'error')
|
||||
+ (n.fn.__location == null ? ' native' : '')
|
||||
,
|
||||
// TODO show `this` argument
|
||||
n.fn.__location == null
|
||||
? fn_link(n.fn)
|
||||
: n.fn.name
|
||||
,
|
||||
'(' ,
|
||||
...join(
|
||||
n.args.map(
|
||||
a => typeof(a) == 'function'
|
||||
? fn_link(a)
|
||||
: stringify_for_header(a)
|
||||
)
|
||||
),
|
||||
')' ,
|
||||
// TODO: show error message only where it was thrown, not every frame?
|
||||
': ', (n.ok ? stringify_for_header(n.value) : n.error.toString())
|
||||
),
|
||||
),
|
||||
(n.children == null || !is_expanded)
|
||||
? null
|
||||
: n.children.map(c => this.render_node(c, current_node))
|
||||
)
|
||||
|
||||
this.node_to_el.set(n.id, result)
|
||||
|
||||
result.is_expanded = is_expanded
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
render_active(node, is_active) {
|
||||
const dom = this.node_to_el.get(node.id).getElementsByClassName('call_el')[0]
|
||||
if(is_active) {
|
||||
dom.classList.add('active')
|
||||
} else {
|
||||
dom.classList.remove('active')
|
||||
}
|
||||
}
|
||||
|
||||
render_select_node(state) {
|
||||
this.render_active(this.state.current_calltree_node, false)
|
||||
this.state = state
|
||||
this.render_active(this.state.current_calltree_node, true)
|
||||
scrollIntoViewIfNeeded(
|
||||
this.container,
|
||||
this.node_to_el.get(this.state.current_calltree_node.id).getElementsByClassName('call_el')[0]
|
||||
)
|
||||
}
|
||||
|
||||
render_expand_node(state) {
|
||||
this.state = state
|
||||
const current_node = this.state.current_calltree_node
|
||||
const prev_dom_node = this.node_to_el.get(current_node.id)
|
||||
const next = this.render_node(current_node, current_node)
|
||||
prev_dom_node.parentNode.replaceChild(next, prev_dom_node)
|
||||
}
|
||||
|
||||
// TODO on hover highlight line where function defined/
|
||||
// TODO hover ?
|
||||
render_calltree(state){
|
||||
this.clear_calltree()
|
||||
this.state = state
|
||||
const root = root_calltree_node(this.state)
|
||||
const current_node = state.current_calltree_node
|
||||
this.container.appendChild(this.render_node(root, current_node))
|
||||
this.render_select_node(state, root, current_node)
|
||||
}
|
||||
}
|
||||
117
src/editor/domutils.js
Normal file
117
src/editor/domutils.js
Normal file
@@ -0,0 +1,117 @@
|
||||
export function el(tag, className, ...children){
|
||||
const result = document.createElement(tag)
|
||||
if(typeof(className) == 'string'){
|
||||
result.setAttribute('class', className)
|
||||
} else {
|
||||
const attrs = className
|
||||
for(let attrName in attrs){
|
||||
const value = attrs[attrName]
|
||||
if(['change','click'].includes(attrName)){
|
||||
result.addEventListener(attrName, value)
|
||||
} else if(attrName == 'checked') {
|
||||
if(attrs[attrName]){
|
||||
result.setAttribute(attrName, "checked")
|
||||
}
|
||||
} else {
|
||||
result.setAttribute(attrName, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
children.forEach(child => {
|
||||
const append = child => {
|
||||
if(typeof(child) == 'undefined') {
|
||||
throw new Error('illegal state')
|
||||
} else if(child !== null) {
|
||||
result.appendChild(
|
||||
typeof(child) == 'string'
|
||||
? document.createTextNode(child)
|
||||
: child
|
||||
)
|
||||
}
|
||||
}
|
||||
if(Array.isArray(child)) {
|
||||
child.forEach(append)
|
||||
} else {
|
||||
append(child)
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
export function stringify(val){
|
||||
function fn_to_str(fn){
|
||||
// TODO if name is 'anonymous', then change name for code
|
||||
return fn.__location == null
|
||||
? `<span>${fn.name}</span>`
|
||||
: `<a
|
||||
href='javascript:void(0)'
|
||||
data-location=${JSON.stringify(fn.__location)}
|
||||
><i>fn</i> ${fn.name}</a>`
|
||||
}
|
||||
if(typeof(val) == 'undefined') {
|
||||
return 'undefined'
|
||||
} else if(typeof(val) == 'function'){
|
||||
return fn_to_str(val)
|
||||
} else {
|
||||
return JSON.stringify(val, (key, value) => {
|
||||
if(typeof(value) == 'function'){
|
||||
return fn_to_str(value)
|
||||
} else {
|
||||
return value
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function fn_link(fn){
|
||||
const str = stringify(fn)
|
||||
const c = document.createElement('div')
|
||||
c.innerHTML = str
|
||||
return c.children[0]
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Idea is borrowed from:
|
||||
// https://mhk-bit.medium.com/scroll-into-view-if-needed-10a96e0bdb61
|
||||
// https://stackoverflow.com/questions/37137450/scroll-all-nested-scrollbars-to-bring-an-html-element-into-view
|
||||
export const scrollIntoViewIfNeeded = (container, target) => {
|
||||
|
||||
// Target is outside the viewport from the top
|
||||
if(target.offsetTop - container.scrollTop - container.offsetTop < 0){
|
||||
// The top of the target will be aligned to the top of the visible area of the scrollable ancestor
|
||||
target.scrollIntoView(true);
|
||||
// Do not scroll horizontally
|
||||
container.scrollLeft = 0
|
||||
}
|
||||
|
||||
// Target is outside the view from the bottom
|
||||
if(target.offsetTop - container.scrollTop - container.offsetTop - container.clientHeight + target.clientHeight > 0) {
|
||||
// The bottom of the target will be aligned to the bottom of the visible area of the scrollable ancestor.
|
||||
target.scrollIntoView(false);
|
||||
// Do not scroll horizontally
|
||||
container.scrollLeft = 0
|
||||
}
|
||||
|
||||
/*
|
||||
Also works
|
||||
|
||||
// Target is outside the view from the top
|
||||
if (target.getBoundingClientRect().y < container.getBoundingClientRect().y) {
|
||||
// The top of the target will be aligned to the top of the visible area of the scrollable ancestor
|
||||
target.scrollIntoView();
|
||||
}
|
||||
|
||||
// Target is outside the view from the bottom
|
||||
if (
|
||||
target.getBoundingClientRect().bottom - container.getBoundingClientRect().bottom +
|
||||
// Adjust for scrollbar size
|
||||
container.offsetHeight - container.clientHeight
|
||||
> 0
|
||||
) {
|
||||
// The bottom of the target will be aligned to the bottom of the visible area of the scrollable ancestor.
|
||||
target.scrollIntoView(false);
|
||||
}
|
||||
*/
|
||||
|
||||
};
|
||||
469
src/editor/editor.js
Normal file
469
src/editor/editor.js
Normal file
@@ -0,0 +1,469 @@
|
||||
import {exec, get_state} from '../index.js'
|
||||
import {ValueExplorer} from './value_explorer.js'
|
||||
import {el, stringify, fn_link} from './domutils.js'
|
||||
import {FLAGS} from '../feature_flags.js'
|
||||
|
||||
/*
|
||||
normalize events 'change' and 'changeSelection':
|
||||
- change is debounced
|
||||
- changeSelection must not fire if 'change' is fired. So for every keystroke,
|
||||
either change or changeSelection should be fired, not both
|
||||
- changeSelection fired only once (ace fires it multiple times for single
|
||||
keystroke)
|
||||
*/
|
||||
const normalize_events = (ace_editor, {
|
||||
on_change,
|
||||
on_change_selection,
|
||||
is_change_selection_supressed,
|
||||
on_change_immediate,
|
||||
}) => {
|
||||
const TIMEOUT = 1000
|
||||
|
||||
let state
|
||||
|
||||
const set_initial_state = () => {
|
||||
state = {}
|
||||
}
|
||||
|
||||
set_initial_state()
|
||||
|
||||
const flush = () => {
|
||||
if(state.change_args != null) {
|
||||
on_change(...state.change_args)
|
||||
} else if(state.change_selection_args != null) {
|
||||
on_change_selection(...state.change_selection_args)
|
||||
}
|
||||
set_initial_state()
|
||||
}
|
||||
|
||||
ace_editor.on('change', (...args) => {
|
||||
on_change_immediate()
|
||||
|
||||
if(state.tid != null) {
|
||||
clearTimeout(state.tid)
|
||||
}
|
||||
|
||||
state.change_args = args
|
||||
|
||||
state.tid = setTimeout(() => {
|
||||
state.tid = null
|
||||
flush()
|
||||
}, TIMEOUT)
|
||||
})
|
||||
|
||||
ace_editor.on('changeSelection', (...args) => {
|
||||
if(is_change_selection_supressed()) {
|
||||
return
|
||||
}
|
||||
if(state.tid != null) {
|
||||
// flush is already by `change`, skip `changeSelection`
|
||||
return
|
||||
}
|
||||
state.change_selection_args = args
|
||||
if(!state.is_flush_set) {
|
||||
state.is_flush_set = true
|
||||
Promise.resolve().then(() => {
|
||||
if(state.tid == null) {
|
||||
flush()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export class Editor {
|
||||
|
||||
constructor(ui, editor_container){
|
||||
this.ui = ui
|
||||
this.editor_container = editor_container
|
||||
|
||||
this.markers = {}
|
||||
this.sessions = {}
|
||||
|
||||
this.ace_editor = ace.edit(this.editor_container)
|
||||
|
||||
this.ace_editor.setOptions({
|
||||
behavioursEnabled: false,
|
||||
// Scroll past end for value explorer
|
||||
scrollPastEnd: 100 /* Allows to scroll 100*<screen size> */,
|
||||
})
|
||||
|
||||
normalize_events(this.ace_editor, {
|
||||
on_change: () => {
|
||||
try {
|
||||
exec('input', this.ace_editor.getValue(), this.get_caret_position())
|
||||
} catch(e) {
|
||||
// Do not throw Error to ACE because it breaks typing
|
||||
console.error(e)
|
||||
this.ui.set_status(e.message)
|
||||
}
|
||||
},
|
||||
|
||||
on_change_immediate: () => {
|
||||
this.update_value_explorer_margin()
|
||||
},
|
||||
|
||||
on_change_selection: () => {
|
||||
try {
|
||||
if(!this.is_change_selection_supressed) {
|
||||
exec('move_cursor', this.get_caret_position())
|
||||
}
|
||||
} catch(e) {
|
||||
// Do not throw Error to ACE because it breaks typing
|
||||
console.error(e)
|
||||
this.ui.set_status(e.message)
|
||||
}
|
||||
},
|
||||
|
||||
is_change_selection_supressed: () => {
|
||||
return this.is_change_selection_supressed
|
||||
}
|
||||
})
|
||||
|
||||
this.focus()
|
||||
|
||||
this.init_keyboard()
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.ace_editor.focus()
|
||||
}
|
||||
|
||||
supress_change_selection(action) {
|
||||
try {
|
||||
this.is_change_selection_supressed = true
|
||||
action()
|
||||
} finally {
|
||||
this.is_change_selection_supressed = false
|
||||
}
|
||||
}
|
||||
|
||||
ensure_session(file, code) {
|
||||
let session = this.sessions[file]
|
||||
if(session == null) {
|
||||
session = ace.createEditSession(code)
|
||||
this.sessions[file] = session
|
||||
session.setUseWorker(false)
|
||||
session.setOptions({
|
||||
mode: "ace/mode/javascript",
|
||||
tabSize: 2,
|
||||
useSoftTabs: true,
|
||||
})
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
get_session(file) {
|
||||
return this.sessions[file]
|
||||
}
|
||||
|
||||
switch_session(file) {
|
||||
// Supress selection change triggered by switching sessions
|
||||
this.supress_change_selection(() => {
|
||||
this.ace_editor.setSession(this.get_session(file))
|
||||
})
|
||||
}
|
||||
|
||||
unembed_value_explorer() {
|
||||
if(this.widget != null) {
|
||||
this.ace_editor.getSession().widgetManager.removeLineWidget(this.widget)
|
||||
this.widget = null
|
||||
}
|
||||
}
|
||||
|
||||
update_value_explorer_margin() {
|
||||
if(this.widget != null) {
|
||||
this.widget.content.style.marginLeft =
|
||||
(this.ace_editor.getSession().getScreenWidth() + 1) + 'ch'
|
||||
}
|
||||
}
|
||||
|
||||
embed_value_explorer({index, result: {ok, value, error}}) {
|
||||
this.unembed_value_explorer()
|
||||
|
||||
const session = this.ace_editor.getSession()
|
||||
const pos = session.doc.indexToPosition(index)
|
||||
const row = pos.row
|
||||
|
||||
const line_height = this.ace_editor.renderer.lineHeight
|
||||
|
||||
let content
|
||||
const container = el('div', {'class': 'embed_value_explorer_container'},
|
||||
el('div', {'class': 'embed_value_explorer_wrapper'},
|
||||
content = el('div', {
|
||||
// Ace editor cannot render widget before the first line. So we
|
||||
// render in on the next line and apply translate
|
||||
'style': `transform: translate(0px, -${line_height}px)`,
|
||||
'class': 'embed_value_explorer_content',
|
||||
tabindex: 0
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
let initial_scroll_top
|
||||
|
||||
const escape = () => {
|
||||
if(initial_scroll_top != null) {
|
||||
// restore scroll
|
||||
session.setScrollTop(initial_scroll_top)
|
||||
}
|
||||
if(this.widget.return_to == null) {
|
||||
this.focus()
|
||||
} else {
|
||||
this.widget.return_to.focus()
|
||||
}
|
||||
// TODO select root in value explorer
|
||||
}
|
||||
|
||||
container.addEventListener('keydown', e => {
|
||||
if(e.key == 'Escape') {
|
||||
escape()
|
||||
}
|
||||
})
|
||||
|
||||
if(ok) {
|
||||
const exp = new ValueExplorer({
|
||||
container: content,
|
||||
event_target: container,
|
||||
on_escape: escape,
|
||||
scroll_to_element: t => {
|
||||
if(initial_scroll_top == null) {
|
||||
initial_scroll_top = session.getScrollTop()
|
||||
}
|
||||
let scroll
|
||||
const out_of_bottom = t.getBoundingClientRect().bottom - this.editor_container.getBoundingClientRect().bottom
|
||||
if(out_of_bottom > 0) {
|
||||
session.setScrollTop(session.getScrollTop() + out_of_bottom)
|
||||
}
|
||||
const out_of_top = this.editor_container.getBoundingClientRect().top - t.getBoundingClientRect().top
|
||||
if(out_of_top > 0) {
|
||||
session.setScrollTop(session.getScrollTop() - out_of_top)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
exp.render(value)
|
||||
} else {
|
||||
content.appendChild(el('span', 'eval_error', error.toString()))
|
||||
}
|
||||
|
||||
this.widget = {
|
||||
row,
|
||||
fixedWidth: true,
|
||||
el: container,
|
||||
content,
|
||||
}
|
||||
|
||||
this.update_value_explorer_margin()
|
||||
|
||||
const LineWidgets = require("ace/line_widgets").LineWidgets;
|
||||
if (!session.widgetManager) {
|
||||
session.widgetManager = new LineWidgets(session);
|
||||
session.widgetManager.attach(this.ace_editor);
|
||||
}
|
||||
session.widgetManager.addLineWidget(this.widget)
|
||||
}
|
||||
|
||||
focus_value_explorer(return_to) {
|
||||
if(FLAGS.embed_value_explorer) {
|
||||
if(this.widget != null) {
|
||||
this.widget.return_to = return_to
|
||||
this.widget.content.focus({preventScroll: true})
|
||||
}
|
||||
} else {
|
||||
if(get_state().selection_state != null) {
|
||||
this.ui.eval.focus_value_or_error()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set_keyboard_handler(type) {
|
||||
if(type != null) {
|
||||
localStorage.keyboard = type
|
||||
}
|
||||
this.ace_editor.setKeyboardHandler(
|
||||
type == 'vim' ? "ace/keyboard/vim" : null
|
||||
)
|
||||
}
|
||||
|
||||
init_keyboard(){
|
||||
this.set_keyboard_handler(localStorage.keyboard)
|
||||
|
||||
const VimApi = require("ace/keyboard/vim").CodeMirror.Vim
|
||||
|
||||
|
||||
this.ace_editor.commands.bindKey("F1", "switch_window");
|
||||
VimApi._mapCommand({
|
||||
keys: '<C-w>',
|
||||
type: 'action',
|
||||
action: 'aceCommand',
|
||||
actionArgs: { name: "switch_window" }
|
||||
})
|
||||
this.ace_editor.commands.addCommand({
|
||||
name: 'switch_window',
|
||||
exec: (editor) => {
|
||||
this.ui.calltree_container.focus()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
this.ace_editor.commands.bindKey("F3", "goto_definition");
|
||||
VimApi._mapCommand({
|
||||
keys: 'gd',
|
||||
type: 'action',
|
||||
action: 'aceCommand',
|
||||
actionArgs: { name: "goto_definition" }
|
||||
})
|
||||
this.ace_editor.commands.addCommand({
|
||||
name: 'goto_definition',
|
||||
exec: (editor) => {
|
||||
this.goto_definition()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
this.ace_editor.commands.bindKey("F2", "focus_value_explorer");
|
||||
this.ace_editor.commands.addCommand({
|
||||
name: 'focus_value_explorer',
|
||||
exec: (editor) => {
|
||||
this.focus_value_explorer()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
this.ace_editor.commands.bindKey("ctrl-i", 'step_into')
|
||||
VimApi._mapCommand({
|
||||
keys: '\\i',
|
||||
type: 'action',
|
||||
action: 'aceCommand',
|
||||
actionArgs: { name: "step_into" }
|
||||
})
|
||||
this.ace_editor.commands.addCommand({
|
||||
name: 'step_into',
|
||||
exec: (editor) => {
|
||||
exec('step_into', this.get_caret_position())
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
this.ace_editor.commands.bindKey("ctrl-o", 'step_out')
|
||||
VimApi._mapCommand({
|
||||
keys: '\\o',
|
||||
type: 'action',
|
||||
action: 'aceCommand',
|
||||
actionArgs: { name: "step_out" }
|
||||
})
|
||||
this.ace_editor.commands.addCommand({
|
||||
name: 'step_out',
|
||||
exec: (editor) => {
|
||||
exec('calltree.arrow_left')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
this.ace_editor.commands.addCommand({
|
||||
name: 'expand_selection',
|
||||
exec: () => {
|
||||
exec('eval_selection', this.get_caret_position(), true)
|
||||
}
|
||||
})
|
||||
this.ace_editor.commands.addCommand({
|
||||
name: 'collapse_selection',
|
||||
exec: () => {
|
||||
exec('eval_selection', this.get_caret_position(), false)
|
||||
}
|
||||
})
|
||||
this.ace_editor.commands.bindKey("ctrl-j", 'expand_selection')
|
||||
this.ace_editor.commands.bindKey("ctrl-down", 'expand_selection')
|
||||
this.ace_editor.commands.bindKey("ctrl-k", 'collapse_selection')
|
||||
this.ace_editor.commands.bindKey("ctrl-up", 'collapse_selection')
|
||||
|
||||
|
||||
this.ace_editor.commands.addCommand({
|
||||
name: 'edit',
|
||||
exec: (editor, input) => {
|
||||
const module = input.args == null ? '' : input.args[0]
|
||||
exec('change_current_module', module)
|
||||
}
|
||||
})
|
||||
VimApi.defineEx("edit", "e", function(cm, input) {
|
||||
cm.ace.execCommand("edit", input)
|
||||
})
|
||||
|
||||
// TODO remove my custom binding
|
||||
VimApi.map('jj', '<Esc>', 'insert')
|
||||
}
|
||||
|
||||
add_marker(file, className, from, to){
|
||||
const session = this.get_session(file)
|
||||
const from_pos = session.doc.indexToPosition(from)
|
||||
const to_pos = session.doc.indexToPosition(to)
|
||||
const markerId = session.addMarker(
|
||||
new ace.Range(from_pos.row,from_pos.column,to_pos.row,to_pos.column),
|
||||
className
|
||||
)
|
||||
if(this.markers[file] == null){
|
||||
this.markers[file] = []
|
||||
}
|
||||
this.markers[file].push({className, from, to, markerId})
|
||||
}
|
||||
|
||||
remove_markers_of_type(file, type){
|
||||
if(this.markers[file] == null){
|
||||
this.markers[file] = []
|
||||
}
|
||||
const for_removal = this.markers[file].filter(h => h.className == type)
|
||||
const session = this.get_session(file)
|
||||
for(let marker of for_removal){
|
||||
session.removeMarker(marker.markerId)
|
||||
}
|
||||
this.markers[file] = this.markers[file].filter(h => h.className != type)
|
||||
}
|
||||
|
||||
|
||||
get_caret_position(file){
|
||||
const session = file == null
|
||||
? this.ace_editor.getSession()
|
||||
: this.get_session(file)
|
||||
|
||||
// Session was not created for file
|
||||
if(session == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return session.doc.positionToIndex(session.selection.getCursor())
|
||||
}
|
||||
|
||||
set_caret_position(index){
|
||||
if(index == null) {
|
||||
throw new Error('illegal state')
|
||||
}
|
||||
|
||||
const pos = this.ace_editor.session.doc.indexToPosition(index)
|
||||
console.log('set caret position', index, pos)
|
||||
|
||||
this.supress_change_selection(() => {
|
||||
const pos = this.ace_editor.session.doc.indexToPosition(index)
|
||||
this.ace_editor.moveCursorToPosition(pos)
|
||||
// Moving cursor performs selection, clear it
|
||||
this.ace_editor.clearSelection()
|
||||
const first = this.ace_editor.renderer.getFirstVisibleRow()
|
||||
const last = this.ace_editor.renderer.getLastVisibleRow()
|
||||
if(pos.row < first || pos.row > last) {
|
||||
this.ace_editor.scrollToLine(pos.row)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
goto_definition(){
|
||||
const index = this.get_caret_position()
|
||||
exec('goto_definition', index)
|
||||
}
|
||||
|
||||
for_each_session(cb) {
|
||||
for(let file in this.sessions) {
|
||||
cb(file, this.sessions[file])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
67
src/editor/eval.js
Normal file
67
src/editor/eval.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import {ValueExplorer} from './value_explorer.js'
|
||||
import {el} from './domutils.js'
|
||||
|
||||
export class Eval {
|
||||
|
||||
constructor(ui, container) {
|
||||
this.ui = ui
|
||||
this.container = container
|
||||
|
||||
this.container.addEventListener('keydown', (e) => {
|
||||
if(e.key == 'Escape') {
|
||||
this.escape()
|
||||
}
|
||||
})
|
||||
|
||||
// TODO jump to fn location, view function calls
|
||||
// container.addEventListener('click', jump_to_fn_location)
|
||||
|
||||
}
|
||||
|
||||
escape() {
|
||||
if(this.focusedFrom == null) {
|
||||
this.ui.editor.focus()
|
||||
} else {
|
||||
this.focusedFrom.focus()
|
||||
this.focusedFrom = null
|
||||
}
|
||||
}
|
||||
|
||||
show_value(value){
|
||||
this.container.innerHTML = ''
|
||||
const container = el('div', {'class': 'eval_content', tabindex: 0})
|
||||
this.container.appendChild(container)
|
||||
const explorer = new ValueExplorer({
|
||||
container,
|
||||
on_escape: () => this.escape()
|
||||
})
|
||||
explorer.render(value)
|
||||
}
|
||||
|
||||
show_error(error){
|
||||
this.container.innerHTML = ''
|
||||
this.container.appendChild(el('span', 'eval_error', error.toString()))
|
||||
}
|
||||
|
||||
show_value_or_error({ok, value, error}){
|
||||
if(ok) {
|
||||
this.show_value(value)
|
||||
} else {
|
||||
this.show_error(error)
|
||||
}
|
||||
}
|
||||
|
||||
clear_value_or_error() {
|
||||
this.container.innerHTML = ''
|
||||
}
|
||||
|
||||
focus_value_or_error(from) {
|
||||
this.focusedFrom = from
|
||||
if(this.container.childElementCount != 1) {
|
||||
throw new Error('illegal state')
|
||||
}
|
||||
this.container.children[0].focus()
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
164
src/editor/files.js
Normal file
164
src/editor/files.js
Normal file
@@ -0,0 +1,164 @@
|
||||
import {el} from './domutils.js'
|
||||
import {map_find} from '../utils.js'
|
||||
import {load_dir, create_file} from '../filesystem.js'
|
||||
import {exec, get_state} from '../index.js'
|
||||
|
||||
export class Files {
|
||||
constructor(ui) {
|
||||
this.ui = ui
|
||||
this.el = el('div', 'files_container')
|
||||
this.render(get_state())
|
||||
}
|
||||
|
||||
open_directory() {
|
||||
load_dir(true).then(dir => {
|
||||
exec('load_dir', dir)
|
||||
})
|
||||
}
|
||||
|
||||
render(state) {
|
||||
if(state.project_dir == null) {
|
||||
this.el.innerHTML = ''
|
||||
this.el.appendChild(
|
||||
el('div', 'allow_file_access',
|
||||
el('a', {
|
||||
href: 'javascript:void(0)',
|
||||
click: this.open_directory.bind(this),
|
||||
},
|
||||
`Allow access to local project folder`,
|
||||
),
|
||||
el('div', 'subtitle', `Your files will never leave your device`)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
this.render_files(state.project_dir, state.current_module)
|
||||
}
|
||||
}
|
||||
|
||||
render_files(dir, current_module) {
|
||||
const files = this.el.querySelector('.files')
|
||||
|
||||
const children = [
|
||||
this.render_file({name: '*scratch*', path: ''}, current_module),
|
||||
this.render_file(dir, current_module),
|
||||
]
|
||||
|
||||
if(files == null) {
|
||||
this.el.innerHTML = ''
|
||||
this.el.appendChild(
|
||||
el('div', 'file_actions',
|
||||
el('a', {
|
||||
href: 'javascript: void(0)',
|
||||
click: this.create_file.bind(this, false),
|
||||
},
|
||||
'Create file'
|
||||
),
|
||||
el('a', {
|
||||
href: 'javascript: void(0)',
|
||||
click: this.create_file.bind(this, true),
|
||||
}, 'Create dir'),
|
||||
)
|
||||
)
|
||||
this.el.appendChild(
|
||||
el('div', 'files',
|
||||
children
|
||||
)
|
||||
)
|
||||
} else {
|
||||
// Replace to preserve scroll position
|
||||
files.replaceChildren(...children)
|
||||
}
|
||||
}
|
||||
|
||||
render_file(file, current_module) {
|
||||
const result = el('div', 'file',
|
||||
el('div', {
|
||||
'class': 'file_title' + (file.path == current_module ? ' active' : ''),
|
||||
click: e => this.on_click(e, file)
|
||||
},
|
||||
el('span', 'icon',
|
||||
file.kind == 'directory'
|
||||
? '\u{1F4C1}' // folder icon
|
||||
: '\xa0',
|
||||
),
|
||||
file.name,
|
||||
),
|
||||
file.children == null
|
||||
? null
|
||||
: file.children.map(c => this.render_file(c, current_module))
|
||||
)
|
||||
|
||||
if(file.path == current_module) {
|
||||
this.active_el = result
|
||||
this.active_file = file
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async create_file(is_dir) {
|
||||
|
||||
if(this.active_file == null) {
|
||||
throw new Error('no active file')
|
||||
}
|
||||
|
||||
let name = prompt(`Enter ${is_dir ? 'directory' : 'file'} name`)
|
||||
if(name == null) {
|
||||
return
|
||||
}
|
||||
|
||||
let dir
|
||||
|
||||
const root = get_state().project_dir
|
||||
|
||||
if(this.active_file.path == '' /* scratch */) {
|
||||
// Create in root directory
|
||||
dir = root
|
||||
} else {
|
||||
if(this.active_file.kind == 'directory') {
|
||||
dir = this.active_file
|
||||
} else {
|
||||
|
||||
const find_parent = (dir, parent) => {
|
||||
if(dir.path == this.active_file.path) {
|
||||
return parent
|
||||
}
|
||||
if(dir.children == null) {
|
||||
return null
|
||||
}
|
||||
return map_find(dir.children, c => find_parent(c, dir))
|
||||
}
|
||||
|
||||
dir = find_parent(root)
|
||||
|
||||
if(dir == null) {
|
||||
throw new Error('illegal state')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const path = dir == root ? name : dir.path + '/' + name
|
||||
await create_file(path, is_dir)
|
||||
|
||||
// Reload all files for simplicity
|
||||
load_dir(false).then(dir => {
|
||||
if(is_dir) {
|
||||
exec('load_dir', dir)
|
||||
} else {
|
||||
exec('create_file', dir, path)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
on_click(e, file) {
|
||||
e.stopPropagation()
|
||||
this.active_el.querySelector('.file_title').classList.remove('active')
|
||||
this.active_el = e.currentTarget.parentElement
|
||||
e.currentTarget.classList.add('active')
|
||||
this.active_file = file
|
||||
if(file.kind != 'directory') {
|
||||
exec('change_current_module', file.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
267
src/editor/ui.js
Normal file
267
src/editor/ui.js
Normal file
@@ -0,0 +1,267 @@
|
||||
import {exec, get_state} from '../index.js'
|
||||
import {Editor} from './editor.js'
|
||||
import {Files} from './files.js'
|
||||
import {CallTree} from './calltree.js'
|
||||
import {Eval} from './eval.js'
|
||||
import {el} from './domutils.js'
|
||||
import {FLAGS} from '../feature_flags.js'
|
||||
|
||||
export class UI {
|
||||
constructor(container, state){
|
||||
this.change_entrypoint = this.change_entrypoint.bind(this)
|
||||
|
||||
this.files = new Files(this)
|
||||
|
||||
container.appendChild(
|
||||
(this.root = el('div',
|
||||
'root ' + (FLAGS.embed_value_explorer ? 'embed_value_explorer' : ''),
|
||||
this.editor_container = el('div', 'editor_container'),
|
||||
FLAGS.embed_value_explorer
|
||||
? null
|
||||
: (this.eval_container = el('div', {class: 'eval'})),
|
||||
el('div', 'bottom',
|
||||
this.calltree_container = el('div', {"class": 'calltree', tabindex: 0}),
|
||||
this.problems_container = el('div', {"class": 'problems', tabindex: 0}),
|
||||
this.entrypoint_select = el('div', 'entrypoint_select')
|
||||
),
|
||||
|
||||
this.files.el,
|
||||
|
||||
this.statusbar = el('div', 'statusbar',
|
||||
this.status = el('div', 'status'),
|
||||
this.current_module = el('div', 'current_module'),
|
||||
/*
|
||||
// Fullscreen cancelled on escape, TODO
|
||||
el('a', {
|
||||
"class" : 'request_fullscreen',
|
||||
href: 'javascript:void(0)',
|
||||
click: e => document.body.requestFullscreen(),
|
||||
},
|
||||
'Fullscreen'
|
||||
),
|
||||
*/
|
||||
this.options = el('div', 'options',
|
||||
el('label', {'for': 'standard'},
|
||||
el('input', {
|
||||
id: 'standard',
|
||||
type: 'radio',
|
||||
name: 'keyboard',
|
||||
checked: localStorage.keyboard == 'standard'
|
||||
|| localStorage.keyboard == null,
|
||||
change: () => {
|
||||
this.editor.set_keyboard_handler('standard')
|
||||
}
|
||||
}),
|
||||
'Standard'
|
||||
),
|
||||
el('label', {'for': 'vim'},
|
||||
el('input', {
|
||||
id: 'vim',
|
||||
type: 'radio',
|
||||
name: 'keyboard',
|
||||
checked: localStorage.keyboard == 'vim',
|
||||
change: () => {
|
||||
this.editor.set_keyboard_handler('vim')
|
||||
}
|
||||
}),
|
||||
'VIM'
|
||||
)
|
||||
),
|
||||
el('a', {
|
||||
'class': 'show_help',
|
||||
href: 'javascript: void(0)',
|
||||
click: () => this.help_dialog.showModal(),
|
||||
},
|
||||
'Help',
|
||||
),
|
||||
el('a', {
|
||||
'class': 'github',
|
||||
href: 'https://github.com/leporello-js/leporello-js',
|
||||
target: '__blank',
|
||||
}, 'Github'),
|
||||
this.help_dialog = this.render_help(),
|
||||
)
|
||||
))
|
||||
)
|
||||
|
||||
this.root.addEventListener('keydown', () => this.clear_status(), true)
|
||||
this.root.addEventListener('click', () => this.clear_status(), true)
|
||||
|
||||
this.editor_container.addEventListener('keydown', e => {
|
||||
if(
|
||||
e.key.toLowerCase() == 'w' && e.ctrlKey == true
|
||||
||
|
||||
// We bind F1 later, this one to work from embed_value_explorer
|
||||
e.key == 'F1'
|
||||
){
|
||||
this.calltree_container.focus()
|
||||
}
|
||||
})
|
||||
|
||||
this.calltree_container.addEventListener('keydown', e => {
|
||||
if(
|
||||
(e.key.toLowerCase() == 'w' && e.ctrlKey == true)
|
||||
||
|
||||
e.key == 'Escape'
|
||||
){
|
||||
this.editor.focus()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
if(!FLAGS.embed_value_explorer) {
|
||||
this.eval = new Eval(this, this.eval_container)
|
||||
} else {
|
||||
// Stub
|
||||
this.eval = {
|
||||
show_value_or_error(){},
|
||||
clear_value_or_error(){},
|
||||
focus_value_or_error(){},
|
||||
}
|
||||
}
|
||||
|
||||
this.editor = new Editor(this, this.editor_container)
|
||||
|
||||
this.calltree = new CallTree(this, this.calltree_container)
|
||||
|
||||
// TODO jump to another module
|
||||
// TODO use exec
|
||||
const jump_to_fn_location = (e) => {
|
||||
let loc
|
||||
if((loc = e.target.dataset.location) != null){
|
||||
loc = JSON.parse(loc)
|
||||
this.editor.set_caret_position(loc.index)
|
||||
this.editor.focus()
|
||||
}
|
||||
}
|
||||
|
||||
// TODO when click in calltree, do not jump to location, navigateCallTree
|
||||
// instead
|
||||
this.calltree_container.addEventListener('click', jump_to_fn_location)
|
||||
|
||||
this.render_entrypoint_select(state)
|
||||
this.render_current_module(state.current_module)
|
||||
}
|
||||
|
||||
render_entrypoint_select(state) {
|
||||
this.entrypoint_select.replaceChildren(
|
||||
el('span', 'entrypoint_title', 'entrypoint'),
|
||||
el('select', {
|
||||
click: e => e.stopPropagation(),
|
||||
change: this.change_entrypoint,
|
||||
},
|
||||
Object.keys(state.files).sort().map(f =>
|
||||
el('option',
|
||||
state.entrypoint == f
|
||||
? { value: f, selected: true }
|
||||
: { value: f},
|
||||
f == '' ? "*scratch*" : f
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
change_entrypoint(e) {
|
||||
const file = e.target.value
|
||||
const index = this.editor.get_caret_position(file)
|
||||
// if index is null, session was not created, and index after session
|
||||
// creation will be 0
|
||||
?? 0
|
||||
exec('change_entrypoint', file, index)
|
||||
this.editor.focus()
|
||||
}
|
||||
|
||||
render_calltree(state) {
|
||||
this.calltree_container.style = ''
|
||||
this.problems_container.style = 'display: none'
|
||||
this.calltree.render_calltree(state)
|
||||
}
|
||||
|
||||
render_problems(problems) {
|
||||
this.calltree_container.style = 'display: none'
|
||||
this.problems_container.style = ''
|
||||
this.problems_container.innerHTML = ''
|
||||
problems.forEach(p => {
|
||||
const s = this.editor.get_session(p.module)
|
||||
const pos = s.doc.indexToPosition(p.index)
|
||||
const module = p.module == '' ? "*scratch*" : p.module
|
||||
this.problems_container.appendChild(
|
||||
el('div', 'problem',
|
||||
el('a', {
|
||||
href: 'javascript:void(0)',
|
||||
click: () => exec('goto_problem', p)
|
||||
},
|
||||
`${module}:${pos.row + 1}:${pos.column} - ${p.message}`
|
||||
)
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
set_status(text){
|
||||
this.current_module.style = 'display: none'
|
||||
this.status.style = ''
|
||||
this.status.innerText = text
|
||||
}
|
||||
|
||||
clear_status(){
|
||||
this.render_current_module(get_state().current_module)
|
||||
}
|
||||
|
||||
render_current_module(current_module) {
|
||||
this.status.style = 'display: none'
|
||||
this.current_module.innerText =
|
||||
current_module == ''
|
||||
? '*scratch*'
|
||||
: current_module
|
||||
this.current_module.style = ''
|
||||
}
|
||||
|
||||
render_help() {
|
||||
const options = [
|
||||
['Switch between editor and call tree', 'F1 or Ctrl-w'],
|
||||
['Go from call tree to editor', 'F1 or Esc'],
|
||||
['Focus value explorer', 'F2'],
|
||||
['Navigate value explorer', '← → ↑ ↓ or hjkl'],
|
||||
['Leave value explorer', 'Esc'],
|
||||
['Jump to definition', 'F3', 'gd'],
|
||||
['Expand selection to eval expression', 'Ctrl-↓ or Ctrl-j'],
|
||||
['Collapse selection', 'Ctrl-↑ or Ctrl-k'],
|
||||
['Navigate call tree view', '← → ↑ ↓ or hjkl'],
|
||||
['Step into call', 'Ctrl-i', '\\i'],
|
||||
['Step out of call', 'Ctrl-o', '\\o'],
|
||||
['When in call tree view, jump to return statement', 'Enter'],
|
||||
['When in call tree view, jump to function arguments', 'a'],
|
||||
]
|
||||
return el('dialog', 'help_dialog',
|
||||
el('table', 'help',
|
||||
el('thead', '',
|
||||
el('th', '', 'Action'),
|
||||
el('th', 'key', 'Standard'),
|
||||
el('th', 'key', 'VIM'),
|
||||
),
|
||||
el('tbody', '',
|
||||
options.map(([text, standard, vim]) =>
|
||||
el('tr', '',
|
||||
el('td', '', text),
|
||||
el('td',
|
||||
vim == null
|
||||
? {'class': 'key spanned', colspan: 2}
|
||||
: {'class': 'key'},
|
||||
standard
|
||||
),
|
||||
vim == null
|
||||
? null
|
||||
: el('td', 'key', vim),
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
el('form', {method: 'dialog'},
|
||||
el('button', null, 'Close'),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
356
src/editor/value_explorer.js
Normal file
356
src/editor/value_explorer.js
Normal file
@@ -0,0 +1,356 @@
|
||||
// TODO large arrays/objects
|
||||
// TODO maps, sets
|
||||
// TODO show Errors in red
|
||||
|
||||
import {el, stringify, scrollIntoViewIfNeeded} from './domutils.js'
|
||||
|
||||
const displayed_entries = object => {
|
||||
if(Array.isArray(object)) {
|
||||
return object.map((v, i) => [i, v])
|
||||
} else {
|
||||
const result = Object.entries(object)
|
||||
return (object instanceof Error)
|
||||
? [['message', object.message], ...result]
|
||||
: result
|
||||
}
|
||||
}
|
||||
|
||||
const is_expandable = v => typeof(v) == 'object'
|
||||
&& v != null
|
||||
&& displayed_entries(v).length != 0
|
||||
|
||||
|
||||
export const stringify_for_header = v => {
|
||||
const type = typeof(v)
|
||||
|
||||
if(v === null) {
|
||||
return 'null'
|
||||
} else if(v === undefined) {
|
||||
return 'undefined'
|
||||
} else if(type == 'function') {
|
||||
// TODO clickable link, 'fn', cursive
|
||||
return 'fn ' + v.name
|
||||
} else if(v instanceof Error) {
|
||||
return v.toString()
|
||||
} else if(type == 'object') {
|
||||
if(Array.isArray(v)) {
|
||||
if(v.length == 0) {
|
||||
return '[]'
|
||||
} else {
|
||||
return '[…]'
|
||||
}
|
||||
} else {
|
||||
if(displayed_entries(v).length == 0) {
|
||||
return '{}'
|
||||
} else {
|
||||
return '{…}'
|
||||
}
|
||||
}
|
||||
} else if(type == 'string') {
|
||||
return JSON.stringify(v)
|
||||
} else {
|
||||
return v.toString()
|
||||
}
|
||||
}
|
||||
|
||||
const header = object => {
|
||||
if(typeof(object) == 'undefined') {
|
||||
return 'undefined'
|
||||
} else if(object == null) {
|
||||
return 'null'
|
||||
} else if(typeof(object) == 'object') {
|
||||
if(object instanceof Error) {
|
||||
return object.toString()
|
||||
} else if(Array.isArray(object)) {
|
||||
return '['
|
||||
+ object
|
||||
.map(stringify_for_header)
|
||||
.join(', ')
|
||||
+ ']'
|
||||
} else {
|
||||
const inner = displayed_entries(object)
|
||||
.map(([k,v]) => {
|
||||
const value = stringify_for_header(v)
|
||||
return `${k}: ${value}`
|
||||
})
|
||||
.join(', ')
|
||||
return `{${inner}}`
|
||||
}
|
||||
} else if(typeof(object) == 'function') {
|
||||
// TODO clickable link, 'fn', cursive
|
||||
return 'fn ' + object.name
|
||||
} else if(typeof(object) == 'string') {
|
||||
return JSON.stringify(object)
|
||||
} else {
|
||||
return object.toString()
|
||||
}
|
||||
return header
|
||||
}
|
||||
|
||||
const get_path = (o, path) => {
|
||||
if(path.length == 0) {
|
||||
return o
|
||||
} else {
|
||||
const [start, ...rest] = path
|
||||
return get_path(o[start], rest)
|
||||
}
|
||||
}
|
||||
|
||||
export class ValueExplorer {
|
||||
|
||||
constructor({
|
||||
container,
|
||||
event_target = container,
|
||||
scroll_to_element,
|
||||
on_escape = () => {},
|
||||
} = {}
|
||||
) {
|
||||
this.container = container
|
||||
this.scroll_to_element = scroll_to_element
|
||||
this.on_escape = on_escape
|
||||
|
||||
event_target.addEventListener('keydown', (e) => {
|
||||
|
||||
/*
|
||||
Right -
|
||||
- does not has children - nothing
|
||||
- has children - first click expands, second jumps to first element
|
||||
|
||||
Left -
|
||||
- root - nothing
|
||||
- not root collapse node, goes to parent if already collapsed
|
||||
|
||||
Up - goes to prev visible element
|
||||
Down - goes to next visible element
|
||||
|
||||
Click - select and toggles expand
|
||||
*/
|
||||
|
||||
const current_object = get_path(this.value, this.current_path)
|
||||
|
||||
if(e.key == 'ArrowDown' || e.key == 'j'){
|
||||
// Do not scroll
|
||||
e.preventDefault()
|
||||
|
||||
if(is_expandable(current_object) && this.is_expanded(this.current_path)) {
|
||||
this.select_path(this.current_path.concat(
|
||||
displayed_entries(current_object)[0][0]
|
||||
))
|
||||
} else {
|
||||
const next = p => {
|
||||
if(p.length == 0) {
|
||||
return null
|
||||
}
|
||||
const parent = p.slice(0, p.length - 1)
|
||||
const children = displayed_entries(get_path(this.value, parent))
|
||||
const child_index = children.findIndex(([k,v]) =>
|
||||
k == p[p.length - 1]
|
||||
)
|
||||
const next_child = children[child_index + 1]
|
||||
if(next_child == null) {
|
||||
return next(parent)
|
||||
} else {
|
||||
return [...parent, next_child[0]]
|
||||
}
|
||||
}
|
||||
|
||||
const next_path = next(this.current_path)
|
||||
if(next_path != null) {
|
||||
this.select_path(next_path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(e.key == 'ArrowUp' || e.key == 'k'){
|
||||
// Do not scroll
|
||||
e.preventDefault()
|
||||
|
||||
if(this.current_path.length == 0) {
|
||||
this.on_escape()
|
||||
return
|
||||
}
|
||||
const parent = this.current_path.slice(0, this.current_path.length - 1)
|
||||
const children = displayed_entries(get_path(this.value, parent))
|
||||
const child_index = children.findIndex(([k,v]) =>
|
||||
k == this.current_path[this.current_path.length - 1]
|
||||
)
|
||||
const next_child = children[child_index - 1]
|
||||
if(next_child == null) {
|
||||
this.select_path(parent)
|
||||
} else {
|
||||
const last = p => {
|
||||
if(!is_expandable(get_path(this.value, p)) || !this.is_expanded(p)) {
|
||||
return p
|
||||
} else {
|
||||
const children = displayed_entries(get_path(this.value, p))
|
||||
.map(([k,v]) => k)
|
||||
return last([...p, children[children.length - 1]])
|
||||
|
||||
}
|
||||
}
|
||||
this.select_path(last([...parent, next_child[0]]))
|
||||
}
|
||||
}
|
||||
|
||||
if(e.key == 'ArrowLeft' || e.key == 'h'){
|
||||
// Do not scroll
|
||||
e.preventDefault()
|
||||
|
||||
const is_expanded = this.is_expanded(this.current_path)
|
||||
if(!is_expandable(current_object) || !is_expanded) {
|
||||
if(this.current_path.length != 0) {
|
||||
const parent = this.current_path.slice(0, this.current_path.length - 1)
|
||||
this.select_path(parent)
|
||||
} else {
|
||||
this.on_escape()
|
||||
}
|
||||
} else {
|
||||
this.toggle_expanded()
|
||||
}
|
||||
}
|
||||
|
||||
if(e.key == 'ArrowRight' || e.key == 'l'){
|
||||
// Do not scroll
|
||||
e.preventDefault()
|
||||
|
||||
if(is_expandable(current_object)) {
|
||||
const is_expanded = this.is_expanded(this.current_path)
|
||||
if(!is_expanded) {
|
||||
this.toggle_expanded()
|
||||
} else {
|
||||
const children = displayed_entries(get_path(this.value, this.current_path))
|
||||
this.select_path(
|
||||
[
|
||||
...this.current_path,
|
||||
children[0][0],
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
get_node_data(path, node_data = this.node_data) {
|
||||
if(path.length == 0) {
|
||||
return node_data
|
||||
} else {
|
||||
const [start, ...rest] = path
|
||||
return this.get_node_data(rest, node_data.children[start])
|
||||
}
|
||||
}
|
||||
|
||||
is_expanded(path) {
|
||||
return this.get_node_data(path).is_expanded
|
||||
}
|
||||
|
||||
on_click(path) {
|
||||
this.select_path(path)
|
||||
this.toggle_expanded()
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.container.innerHTML = ''
|
||||
this.node_data = {is_expanded: true}
|
||||
}
|
||||
|
||||
render(value) {
|
||||
this.clear()
|
||||
this.value = value
|
||||
const path = []
|
||||
this.container.appendChild(this.render_value_explorer_node(null, value, path, this.node_data))
|
||||
this.select_path(path)
|
||||
}
|
||||
|
||||
select_path(current_path) {
|
||||
if(this.current_path != null) {
|
||||
this.set_active(this.current_path, false)
|
||||
}
|
||||
this.current_path = current_path
|
||||
this.set_active(this.current_path, true)
|
||||
// Check that was already added to document
|
||||
if(document.contains(this.container)) {
|
||||
const target = this.get_node_data(current_path).el.getElementsByClassName('value_explorer_header')[0]
|
||||
if(this.scroll_to_element == null) {
|
||||
scrollIntoViewIfNeeded(this.container.parentNode, target)
|
||||
} else {
|
||||
this.scroll_to_element(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set_active(path, is_active) {
|
||||
const el = this.get_node_data(path).el.getElementsByClassName('value_explorer_header')[0]
|
||||
if(is_active) {
|
||||
el.classList.add('active')
|
||||
} else {
|
||||
el.classList.remove('active')
|
||||
}
|
||||
}
|
||||
|
||||
set_expanded(fn) {
|
||||
if(typeof(fn) == 'boolean') {
|
||||
return this.set_expanded(() => fn)
|
||||
}
|
||||
const val = this.is_expanded(this.current_path)
|
||||
const data = this.get_node_data(this.current_path)
|
||||
data.is_expanded = fn(data.is_expanded)
|
||||
const prev_dom_node = data.el
|
||||
const key = this.current_path.length == 0
|
||||
? null
|
||||
: this.current_path[this.current_path.length - 1]
|
||||
const value = get_path(this.value, this.current_path)
|
||||
const next = this.render_value_explorer_node(key, value, this.current_path, data)
|
||||
prev_dom_node.parentNode.replaceChild(next, prev_dom_node)
|
||||
}
|
||||
|
||||
toggle_expanded() {
|
||||
this.set_expanded(e => !e)
|
||||
this.set_active(this.current_path, true)
|
||||
}
|
||||
|
||||
render_value_explorer_node(key, value, path, node_data) {
|
||||
|
||||
const is_exp = is_expandable(value)
|
||||
const is_expanded = is_exp && node_data.is_expanded
|
||||
|
||||
node_data.children = {}
|
||||
|
||||
const result = el('div', 'value_explorer_node',
|
||||
|
||||
el('span', {
|
||||
class: 'value_explorer_header',
|
||||
click: this.on_click.bind(this, path),
|
||||
},
|
||||
is_exp
|
||||
? (is_expanded ? '▼' : '▶')
|
||||
: '\xa0',
|
||||
|
||||
key == null
|
||||
? null
|
||||
: el('span', 'value_explorer_key', key.toString(), ': '),
|
||||
|
||||
key == null || !is_exp || !is_expanded
|
||||
// Full header
|
||||
? header(value)
|
||||
// Short header
|
||||
: Array.isArray(value)
|
||||
? 'Array(' + value.length + ')'
|
||||
: ''
|
||||
),
|
||||
|
||||
(is_exp && is_expanded)
|
||||
? displayed_entries(value).map(([k,v]) => {
|
||||
node_data.children[k] = {}
|
||||
return this.render_value_explorer_node(k, v, [...path, k], node_data.children[k])
|
||||
})
|
||||
: []
|
||||
)
|
||||
|
||||
node_data.el = result
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user