Files
leporello-js/src/editor/editor.js

562 lines
15 KiB
JavaScript
Raw Normal View History

2022-09-10 02:48:13 +08:00
import {exec, get_state} from '../index.js'
2023-07-31 23:17:48 +03:00
import {ValueExplorer} from './value_explorer.js'
import {stringify_for_header} from '../value_explorer_utils.js'
2022-09-10 02:48:13 +08:00
import {el, stringify, fn_link} from './domutils.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
2023-07-04 20:24:42 +03:00
this.make_resizable()
2022-09-10 02:48:13 +08:00
this.markers = {}
this.sessions = {}
2023-02-07 22:25:05 +08:00
this.ace_editor = globalThis.ace.edit(this.editor_container)
2022-09-10 02:48:13 +08:00
this.ace_editor.setOptions({
behavioursEnabled: false,
// Scroll past end for value explorer
scrollPastEnd: 100 /* Allows to scroll 100*<screen size> */,
2023-06-26 15:12:49 +03:00
enableLiveAutocompletion: false,
enableBasicAutocompletion: true,
2022-09-10 02:48:13 +08:00
})
normalize_events(this.ace_editor, {
on_change: () => {
try {
2022-12-03 03:18:54 +08:00
exec('input', this.ace_editor.getValue(), this.get_cursor_position())
2022-09-10 02:48:13 +08:00
} catch(e) {
// Do not throw Error to ACE because it breaks typing
console.error(e)
this.ui.set_status(e.message)
}
},
on_change_immediate: () => {
2023-07-05 00:18:22 +03:00
this.unembed_value_explorer()
2022-09-10 02:48:13 +08:00
},
on_change_selection: () => {
try {
if(!this.is_change_selection_supressed) {
2022-12-03 03:18:54 +08:00
exec('move_cursor', this.get_cursor_position())
2022-09-10 02:48:13 +08:00
}
} 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) {
2023-02-07 22:25:05 +08:00
session = globalThis.ace.createEditSession(code)
2022-09-10 02:48:13 +08:00
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() {
2023-07-17 05:21:05 +03:00
if(this.widget == null) {
return
}
const session = this.ace_editor.getSession()
const widget_bottom = this.widget.el.getBoundingClientRect().bottom
session.widgetManager.removeLineWidget(this.widget)
if(this.widget.is_dom_el) {
/*
if cursor moves below widget, then ace editor first adjusts scroll,
and then widget gets remove, so scroll jerks. We have to set scroll
back
*/
// distance travelled by cursor
const distance = session.selection.getCursor().row - this.widget.row
if(distance > 0) {
const line_height = this.ace_editor.renderer.lineHeight
const scroll = widget_bottom - this.editor_container.getBoundingClientRect().bottom
if(scroll > 0) {
const scrollTop = session.getScrollTop()
session.setScrollTop(session.getScrollTop() - scroll - line_height*distance)
}
}
2022-09-10 02:48:13 +08:00
}
2023-07-17 05:21:05 +03:00
this.widget = null
2022-09-10 02:48:13 +08:00
}
update_value_explorer_margin() {
if(this.widget != null) {
2023-07-05 00:18:22 +03:00
const session = this.ace_editor.getSession()
// Calculate left margin in such way that value explorer does not cover
// code. It has sufficient left margin so all visible code is to the left
// of it
const lines_count = session.getLength()
let margin = 0
for(
let i = this.widget.row;
2023-07-05 03:35:53 +03:00
i <= this.ace_editor.renderer.getLastVisibleRow();
2023-07-05 00:18:22 +03:00
i++
) {
margin = Math.max(margin, session.getLine(i).length)
}
// Next line sets margin based on whole file
//const margin = this.ace_editor.getSession().getScreenWidth()
this.widget.content.style.marginLeft = (margin + 1) + 'ch'
2022-09-10 02:48:13 +08:00
}
}
2023-06-19 06:33:57 +03:00
embed_value_explorer({index, length, result: {ok, value, error}}) {
2022-09-10 02:48:13 +08:00
this.unembed_value_explorer()
const session = this.ace_editor.getSession()
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
'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()
}
})
2023-07-17 05:21:05 +03:00
let is_dom_el
2022-09-10 02:48:13 +08:00
if(ok) {
2023-07-17 05:21:05 +03:00
if(value instanceof globalThis.app_window.Element && !value.isConnected) {
is_dom_el = true
content.appendChild(value)
} else {
is_dom_el = false
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)
}
},
})
2022-09-10 02:48:13 +08:00
2023-07-17 05:21:05 +03:00
exp.render(value)
}
2022-09-10 02:48:13 +08:00
} else {
2023-07-17 05:21:05 +03:00
is_dom_el = false
2023-02-08 04:09:53 +08:00
content.appendChild(el('span', 'eval_error', stringify_for_header(error)))
2022-09-10 02:48:13 +08:00
}
const widget = this.widget = {
2023-07-17 05:21:05 +03:00
row: is_dom_el
? session.doc.indexToPosition(index + length).row
: session.doc.indexToPosition(index).row,
2022-09-10 02:48:13 +08:00
fixedWidth: true,
el: container,
content,
2023-07-17 05:21:05 +03:00
is_dom_el,
2022-09-10 02:48:13 +08:00
}
if (!session.widgetManager) {
const LineWidgets = require("ace/line_widgets").LineWidgets;
2022-09-10 02:48:13 +08:00
session.widgetManager = new LineWidgets(session);
session.widgetManager.attach(this.ace_editor);
}
2023-07-05 03:35:53 +03:00
2023-07-17 05:21:05 +03:00
if(is_dom_el) {
container.classList.add('is_dom_el')
session.widgetManager.addLineWidget(widget)
2023-07-17 05:21:05 +03:00
} else {
container.classList.add('is_not_dom_el')
const line_height = this.ace_editor.renderer.lineHeight
content.style.transform = `translate(0px, -${line_height}px)`
// update_value_explorer_margin relies on getLastVisibleRow which can be
// incorrect because it may be executed right after set_cursor_position
// which is async in ace_editor. Use setTimeout
setTimeout(() => {
this.update_value_explorer_margin()
session.widgetManager.addLineWidget(widget)
}, 0)
}
2022-09-10 02:48:13 +08:00
}
focus_value_explorer(return_to) {
2023-05-19 15:59:15 +03:00
if(this.widget != null) {
this.widget.return_to = return_to
this.widget.content.focus({preventScroll: true})
2022-09-10 02:48:13 +08:00
}
}
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
2022-10-25 04:43:35 +08:00
// Remove commands binded to function keys that we are going to redefine
this.ace_editor.commands.removeCommand('openCommandPallete')
this.ace_editor.commands.removeCommand('toggleFoldWidget')
this.ace_editor.commands.removeCommand('goToNextError')
2022-09-10 02:48:13 +08:00
2023-02-13 17:39:34 +08:00
this.ace_editor.commands.bindKey("F5", "goto_definition");
2022-09-10 02:48:13 +08:00
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("F1", "focus_value_explorer");
2022-09-10 02:48:13 +08:00
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) => {
2022-12-03 03:18:54 +08:00
exec('step_into', this.get_cursor_position())
2022-09-10 02:48:13 +08:00
}
})
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: () => {
2022-12-03 03:18:54 +08:00
exec('eval_selection', this.get_cursor_position(), true)
2022-09-10 02:48:13 +08:00
}
})
this.ace_editor.commands.addCommand({
name: 'collapse_selection',
exec: () => {
2022-12-03 03:18:54 +08:00
exec('eval_selection', this.get_cursor_position(), false)
2022-09-10 02:48:13 +08:00
}
})
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(
2023-02-07 22:25:05 +08:00
new globalThis.ace.Range(from_pos.row,from_pos.column,to_pos.row,to_pos.column),
2022-09-10 02:48:13 +08:00
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)
}
2022-12-03 03:18:54 +08:00
get_cursor_position(file){
2022-09-10 02:48:13 +08:00
const session = file == null
? this.ace_editor.getSession()
: this.get_session(file)
if(session == null) {
// Session was not created for file
throw new Error('illegal state')
2022-09-10 02:48:13 +08:00
}
return session.doc.positionToIndex(session.selection.getCursor())
}
2022-12-03 03:18:54 +08:00
set_cursor_position(index){
2022-09-10 02:48:13 +08:00
if(index == null) {
throw new Error('illegal state')
}
const pos = this.ace_editor.session.doc.indexToPosition(index)
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(){
2022-12-03 03:18:54 +08:00
const index = this.get_cursor_position()
2022-09-10 02:48:13 +08:00
exec('goto_definition', index)
}
for_each_session(cb) {
for(let file in this.sessions) {
cb(file, this.sessions[file])
}
}
2023-07-04 20:24:42 +03:00
make_resizable() {
const apply_height = () => {
this.editor_container.style.height = localStorage.editor_height + 'vh'
}
let last_resize_time = new Date().getTime()
window.addEventListener('resize', () => {
last_resize_time = new Date().getTime()
})
// Save editor_height on resize and restore it on reopen
if(localStorage.editor_height != null) {
apply_height()
}
let is_first_run = true
new ResizeObserver((e) => {
if(is_first_run) {
// Resize observer callback seems to fire immediately on create
is_first_run = false
return
}
if(new Date().getTime() - last_resize_time < 100) {
// Resize observer triggered by window resize, skip
return
}
// See https://stackoverflow.com/a/57166828/795038
// ace editor must be updated based on container size change
this.ace_editor.resize()
const height = this.editor_container.offsetHeight / window.innerHeight * 100
localStorage.editor_height = height
// resize applies height in pixels. Wait for it and apply height in vh
setTimeout(apply_height, 0)
}).observe(this.editor_container)
}
2022-09-10 02:48:13 +08:00
}