├── menus
├── app.js
├── edit.js
├── file.js
├── help.js
├── view.js
└── window.js
├── .gitignore
├── js
├── autoindent.js
├── importer.js
├── history.js
├── exporter.js
├── tags.js
├── autofill.js
├── ranui.js
├── clipboard.js
├── utils.js
├── parseHTML.js
├── mouse.js
├── keydown.js
├── selection.js
├── editing.js
├── html.json
└── jquery-3.1.1.min.js
├── package.json
├── LICENSE.md
├── index.html
├── README.md
├── filemanager.js
├── main.js
└── css
└── ranui.css
/menus/app.js:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/menus/edit.js:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/menus/file.js:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/menus/help.js:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/menus/view.js:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/menus/window.js:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
--------------------------------------------------------------------------------
/js/autoindent.js:
--------------------------------------------------------------------------------
1 | //TODO
2 | // get reference to topmost node
3 | // get next rows until there's a ro that's less indented than ref
4 | // make a group from ref until found row
5 | // make groups until end of rows
6 | // for each group:
7 | // get indentation of topmost row
8 | // substract that much indentation from each row in group
9 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ranui",
3 | "productName": "Ranui",
4 | "author": "Ville Vanninen",
5 | "version": "0.0.0",
6 | "main": "main.js",
7 | "scripts": {
8 | "start": "electron ."
9 | },
10 | "devDependencies": {
11 | "devtron": "^1.4.0",
12 | "electron": "^1.7.8",
13 | "electron-document-manager": "^0.1.0",
14 | "gumbo-parser": "^0.3.0",
15 | "jquery": "^3.2.1",
16 | "pug": "^2.0.0-rc.4"
17 | },
18 | "dependencies": {
19 | "electron-window-manager": "^1.0.4"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/js/importer.js:
--------------------------------------------------------------------------------
1 | module.exports = {renderDoc, renderRows, renderProps}
2 |
3 | function renderDoc (doc) {
4 | return rows(doc.rows)
5 | }
6 |
7 | function renderRows (rows) {
8 | let out = ''
9 | for (var i = 0; i < rows.length; i++) {
10 | let row = rows[i]
11 | let props = importer.renderProps(row.props)
12 | out += `${props}`
13 | }
14 | return out
15 | }
16 |
17 | function renderProps (props) {
18 | let out = ''
19 | for (let i = 0; i < props.length; i++) {
20 | let prop = props[i]
21 | let type = prop.type
22 | let text = prop.text
23 | out += `<${type} text="${text}" class="new">${text}${type}>`
24 | }
25 | return out
26 | }
27 |
28 |
--------------------------------------------------------------------------------
/js/history.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | function History(initial) {
4 | let index = 0
5 | let stack = [initial]
6 | let isModified = false
7 |
8 | function modified() {
9 | return isModified
10 | }
11 |
12 | function update() {
13 | let item = $('doc')[0].outerHTML
14 | stack[index] = item
15 | isModified = true
16 | }
17 |
18 | function add() {
19 | let item = $('doc')[0].outerHTML
20 | if (item !== stack[index]) {
21 | index = index + 1
22 | stack.splice(index)
23 | stack.push(item)
24 | isModified = true
25 | }
26 | }
27 |
28 | //TODO: sometimes you can undo the document into oblivion, something's wrong
29 | function undo () {
30 | if (scope === 'editing') {
31 | document.execCommand('undo', '', null)
32 | } else if (index > 0) {
33 | index = index - 1
34 | $('doc').replaceWith(stack[index])
35 | }
36 | }
37 |
38 | function redo () {
39 | if (scope === 'editing') {
40 | document.execCommand('redo', '', null)
41 | } else if (index < stack.length - 1) {
42 | index = index + 1
43 | $('doc').replaceWith(stack[index])
44 | }
45 | }
46 |
47 | return {add, update, undo, redo, modified}
48 | }
49 |
50 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | In plain English:
2 |
3 | Spread the idea. Make cool stuff. Give credit where credit is due.
4 |
5 | ----
6 |
7 | Official licence:
8 |
9 | MIT License
10 |
11 | Copyright (c) 2017 Ville Vanninen
12 |
13 | Permission is hereby granted, free of charge, to any person obtaining a copy
14 | of this software and associated documentation files (the "Software"), to deal
15 | in the Software without restriction, including without limitation the rights
16 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17 | copies of the Software, and to permit persons to whom the Software is
18 | furnished to do so, subject to the following conditions:
19 |
20 | The above copyright notice and this permission notice shall be included in all
21 | copies or substantial portions of the Software.
22 |
23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29 | SOFTWARE.
30 |
--------------------------------------------------------------------------------
/js/exporter.js:
--------------------------------------------------------------------------------
1 | //TODO: this should take in ranui dom or html and return plain html
2 | //This needs to also export attributes without a tag and plain text, because this will be used for copy paste too.
3 |
4 | const pug = require.main.require('pug')
5 |
6 | module.exports = {domToPug, pugToHTML}
7 |
8 | function pugToHTML (string) {
9 | return pug.render(string)
10 | }
11 |
12 | function domToPug (rows) {
13 | rows = rows || $('doc').children('selector')
14 | let out = ''
15 |
16 | rows.each(function(i, el) {
17 | let row = $(el)
18 | let rowType = row.attr('type')
19 | let tabs = parseInt(row.attr('tabs'))
20 | let spaces = ' '.repeat(tabs)
21 |
22 | out += spaces
23 | if (row.hasClass('com')) {
24 | out += '//'
25 | }
26 |
27 | if (rowType === 'tag') {
28 | row.children().each(function(i, el) {
29 | let token = $(el)
30 | let tokenType = token[0].tagName
31 | if (tokenType === 'TAG') {
32 | out += token.text() + '('
33 | } else if (tokenType === 'PROP') {
34 | out += token.text()
35 | } else if (tokenType === 'VAL') {
36 | out += '=\'' + token.text() + '\' '
37 | }
38 | })
39 | out += ')'
40 | } else if (rowType === 'txt') {
41 | out += '| ' + row.children().first().html()
42 | }
43 |
44 | out += '\n'
45 |
46 | })
47 |
48 | return out
49 | }
50 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Ranui
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
16 |
17 |
18 |
19 | dividrules
20 | dividuniqueclasssomething
21 | plaintext
22 | plaintext
23 | divclasssomething
24 | divclasstext
25 | div
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Foolproof HTML editor prototype
2 |
3 | The grand idea is to build a native html editor (so not a general text editor that's customized for html, but purpose built for only html) that can handle any html, even with random template code in the middle. Then hopefully expand the editing model to support css, then json and others.
4 |
5 | I wrote a little article on the rationale here
6 |
7 | There's also this, more of a note to self type explainer on how the rows are handled.
8 |
9 | ## Contributing
10 |
11 | My prototype code is a mess, so we're trying to get some proper app arcitecture done. I set up a Gitter chat for planning and sharing stuff. The chat is the best way to contribute right now. https://gitter.im/flprf/Lobby
12 |
13 | The prototype is good enough for demos, but not really usable yet. It's Mac only for now, mainly because doing good multi platform keyboard support would take time off from making it actually work. Contributions welcome!
14 |
15 | ## Running
16 |
17 |
18 | 1. Clone the repo
19 | 2. Run `npm install`
20 | 3. Run `npm start`
21 |
22 |
23 | - Type lowercase to create elements
24 | - Type uppercase to create text
25 | - Press space to add attributes
26 | - Press enter to edit what you have selected.
27 |
28 | You can find most actions in js/keydown.js. Some actions come through the app shell from menu items, the ones you'd expect like undo/redo, saving (TODO), copy & paste etc. The interactions are modelled pretty closely after Sublime Text. I'm hoping to make the UI feel instantly familiar and productive to anyone who's ever written HTML in a text editor.
29 |
30 | Probably needless to say, but expect buggy behaviour. Most stuff seems to be working fine, but that's just me using it.
31 |
32 | Built with [Electron](http://electron.atom.io).
33 |
--------------------------------------------------------------------------------
/js/tags.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | //TODO: order these so that similar tags are ordered by how common they are, so it's mostly alphabetical, but you'll get what you want when you type just a few letters
4 |
5 |
6 | const tags = [
7 | 'a',
8 | 'article',
9 | 'aside',
10 | 'abbr',
11 | 'address',
12 | 'area',
13 | 'audio',
14 | 'b',
15 | 'base',
16 | 'bdi',
17 | 'bdo',
18 | 'blockquote',
19 | 'body',
20 | 'br',
21 | 'button',
22 | 'canvas',
23 | 'caption',
24 | 'cite',
25 | 'code',
26 | 'col',
27 | 'colgroup',
28 | 'command',
29 | 'div',
30 | 'datalist',
31 | 'dd',
32 | 'del',
33 | 'details',
34 | 'dfn',
35 | 'dl',
36 | 'dt',
37 | 'em',
38 | 'embed',
39 | 'fieldset',
40 | 'figcaption',
41 | 'figure',
42 | 'footer',
43 | 'form',
44 | 'h1',
45 | 'h2',
46 | 'h3',
47 | 'h4',
48 | 'h5',
49 | 'h6',
50 | 'head',
51 | 'header',
52 | 'hgroup',
53 | 'hr',
54 | 'html',
55 | 'i',
56 | 'iframe',
57 | 'img',
58 | 'input',
59 | 'ins',
60 | 'kbd',
61 | 'keygen',
62 | 'label',
63 | 'legend',
64 | 'li',
65 | 'link',
66 | 'map',
67 | 'mark',
68 | 'menu',
69 | 'meta',
70 | 'meter',
71 | 'nav',
72 | 'noscript',
73 | 'object',
74 | 'ol',
75 | 'optgroup',
76 | 'option',
77 | 'output',
78 | 'p',
79 | 'param',
80 | 'pre',
81 | 'progress',
82 | 'q',
83 | 'rp',
84 | 'rt',
85 | 'ruby',
86 | 's',
87 | 'samp',
88 | 'script',
89 | 'section',
90 | 'select',
91 | 'small',
92 | 'source',
93 | 'span',
94 | 'strong',
95 | 'style',
96 | 'sub',
97 | 'summary',
98 | 'sup',
99 | 'table',
100 | 'tbody',
101 | 'td',
102 | 'textarea',
103 | 'tfoot',
104 | 'th',
105 | 'thead',
106 | 'time',
107 | 'title',
108 | 'tr',
109 | 'track',
110 | 'u',
111 | 'ul',
112 | 'var',
113 | 'video',
114 | 'wbr',
115 | ]
116 |
117 |
118 | module.exports = tags
119 |
--------------------------------------------------------------------------------
/js/autofill.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | //TODO: eventually this module should use a fuzzy search and show an autocomplete list.
4 |
5 | //Chrome has rudimentary datalist element support, but it sucks, so we need our own implementation
6 |
7 | module.exports = {prevent, fill, processNode}
8 |
9 | let isPrevented = false
10 |
11 | function prevent () {
12 | isPrevented = true
13 | }
14 |
15 | function fill (node) {
16 | if (isPrevented) {
17 | isPrevented = false
18 | } else {
19 | processNode(node)
20 | }
21 | }
22 |
23 | function processNode (node) {
24 | const textNode = node.childNodes[0]
25 | const text = node.innerText
26 | let autoFillValues = []
27 | let autoFilledText
28 |
29 | //Different behaviour for different types of cases
30 | //autofill could be super smart based on context, like only adding li's inside ul & ol elements, but too aggressive smarts get irritating really quickly, so not doing it for now.
31 | if (node.tagName === 'TAG') {
32 | autoFillValues = tags
33 | } else {
34 | //TODO: add autofill for props based on tag and values based on prop
35 | return //cancel autofill if we're not in a node where there's something to autofill
36 | }
37 |
38 | //If we have an exact match, don't bother with autofill, it's all good
39 | if (!autoFillValues[text]) {
40 | autoFilledText = autoFillValues.find((item)=>{
41 | return item.indexOf(text) === 0
42 | })
43 |
44 | if (autoFilledText) {
45 | const selStart = text.length
46 | const selEnd = autoFilledText.length
47 | node.innerText = autoFilledText
48 | const range = document.createRange()
49 | range.setStart(textNode, selStart)
50 | range.setEnd(textNode, selEnd)
51 | const sel = window.getSelection()
52 | sel.removeAllRanges()
53 | sel.addRange(range)
54 | }
55 | }
56 | }
57 |
58 | function cycle() {
59 | //TODO: up/down arrows should advance autofill to the prev/next match, so you can type 'ar' and get 'area', then 'article'
60 | }
61 |
62 |
--------------------------------------------------------------------------------
/js/ranui.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const {ipcRenderer} = require('electron')
4 | const devtron = require('devtron') //DEBUG, remove for compiled app
5 | devtron.install()
6 |
7 | const autofill = require.main.require('./js/autofill.js')
8 | const tags = require.main.require('./js/tags.js') //list of html tags
9 | const importer = require.main.require('./js/importer.js')
10 | importer.parseHTML = require.main.require('./js/parseHTML.js')
11 | const exporter = require.main.require('./js/exporter.js')
12 | const history = new History()
13 | var scope = ''
14 |
15 | //DEBUG
16 | // console.log(exporter.domToPug($('doc')))
17 | // console.log(exporter.pugToHTML(exporter.domToPug($('doc'))))
18 |
19 | //ranui.js is in global scope, so anything required here will be available through all main scripts. Yeah, should be encapsulated and all that.
20 |
21 |
22 | //I could do most of the editing inputs via OS level menus and their accelerators. Menu items can be hidden, so maybe some menu items wouldn't need to be visible. Most of the could be in the menus too for discoverability and so you could overwrite the shortcuts easier without making custom app level configs.
23 |
24 |
25 | window.addEventListener('keydown', keydown)
26 | window.addEventListener('input', e=>{
27 | if (scope === 'editing') {
28 | input(e.target)
29 | }
30 | })
31 | window.addEventListener('blur', e=>{
32 | if (scope === 'editing') {
33 | //Not sure if window blur should escape editing mode, but that's what happens in devtools too. It kinda feels more solid and predictable if you always have a 'solid' selection when returning to the app
34 | commitEdit()
35 | }
36 | })
37 |
38 | //Copy & paste
39 | //TODO: copypaste events seem to work great. Implement functions for setting/getting data and data parsing via http://electron.atom.io/docs/api/clipboard
40 | window.addEventListener('beforecut', beforeCut)
41 | window.addEventListener('beforecopy', beforeCopy)
42 | window.addEventListener('cut', cut)
43 | window.addEventListener('copy', copy)
44 | window.addEventListener('paste', paste)
45 |
46 |
47 | //Mouse
48 | window.addEventListener('dblclick', e=>{history.update();startEdit(e)})
49 | window.addEventListener('mousedown', e=>mouseDown(e))
50 | window.addEventListener('mousemove', throttle(mouseMove, 16)) //Only running mousemove at max 60fps
51 | window.addEventListener('mouseup', e=>mouseUp(e))
52 |
53 |
54 | //Undo Redo
55 | ipcRenderer.on('undo', history.undo)
56 | ipcRenderer.on('redo', history.redo)
57 |
58 |
59 | //Files
60 | ipcRenderer.on('new', e=>{})
61 | ipcRenderer.on('open', e=>{})
62 | ipcRenderer.on('save', e=>{})
63 | ipcRenderer.on('saveAs', e=>{})
64 | window.addEventListener('beforeunload', e=>{
65 | if (history.modified()) {
66 | //confirm('Save changes?') //TODO: show proper [don't save / cancel / save] dialog
67 | }
68 | })
69 |
--------------------------------------------------------------------------------
/filemanager.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const {
4 | ipcMain,
5 | dialog,
6 | BrowserWindow
7 | } = require('electron')
8 |
9 | const path = require('path')
10 | const url = require('url')
11 |
12 |
13 | //TODO: use macOS native tabs for documents, don't implement any custom tabs. Not enabled in Electron yet, but here's the issue: https://github.com/electron/electron/issues/6124 Tabs have been intentionally disabled, so it shouldn't be hard to add back tab support.
14 |
15 |
16 | const windowList = []
17 |
18 | function newFile (menuItem, browserWindow, event) {
19 | let newWin = new BrowserWindow({
20 | width: 1000,
21 | height: 700,
22 | title: 'Untitled',
23 | //titleBarStyle: 'hidden',
24 | webPreferences: {
25 | scrollBounce: true
26 | },
27 | backgroundColor: '#272822' //Bg color of doc, so even if loading takes a while, the window won't flash white
28 | })
29 | newWin.loadURL(url.format({
30 | pathname: path.join(__dirname, 'index.html'),
31 | protocol: 'file:',
32 | slashes: true
33 | }))
34 | //TODO: win.setRepresentedFilename('filename.html') //this should happen on file open
35 | //TODO: win.setDocumentEdited(true) if not true, this should happen on any edit command in the render process
36 | newWin.webContents.openDevTools() //for debugging
37 |
38 | windowList.push(newWin)
39 |
40 |
41 | newWin.on('closed', ()=> {
42 | windowList.splice(windowList.indexOf(newWin), 1)
43 | newWin = null
44 | })
45 | }
46 |
47 | //TODO: put window management and file open/save stuff in its own module
48 | function open (menuItem, browserWindow, event) {
49 | dialog.showOpenDialog({properties: ['openFile','multiSelections']}, paths=>{
50 | for (let path in paths) {
51 | console.log(path)
52 | }
53 | })
54 | //TODO: open file? spawn new window, give that new window the path for opening or maybe text of the file, parse text inside the new window. Do it this way so any problem in parsing will only ever affect the one window.
55 | //newWindow() needs to take path as a parameter?
56 | }
57 | function close (menuItem, browserWindow, event) {
58 | browserWindow.close()
59 | //TODO: if file contents dirty, call saveFile for browserwindow
60 | }
61 |
62 | function save (menuItem, browserWindow, event) {
63 | //TODO: get frontmost window, parse contents to html, show save dialog
64 | //maybe take in filename as a parameter or something, need to handle saveAs, saveAll too
65 | //maybe this should get the browserwindow as a parameter? so it would be easy to use this with saveAll
66 |
67 | }
68 | function saveAs (menuItem, browserWindow, event) {
69 | dialog.showSaveDialog()
70 | //etc...
71 | }
72 | function saveAll (menuItem, browserWindow, event) {
73 | //TODO: call saveFile for each browserwindow
74 | }
75 |
76 |
77 |
78 | module.exports = {newFile, open, close, save, saveAs, saveAll}
79 |
--------------------------------------------------------------------------------
/js/clipboard.js:
--------------------------------------------------------------------------------
1 | function beforeCopy (e) {
2 | console.log('beforeCopy')
3 | }
4 | function copy (e) {
5 | e.preventDefault()
6 |
7 | if (scope === '') {
8 | let selRows = $('.hilite')
9 | if (selRows.length) {
10 | e.preventDefault()
11 | /*TODO:
12 | - get .sel
13 | - do the same as drag & drop does when starting to drag
14 | - gather elements
15 | - normalise tabs
16 | - use exporter.domToPug to render to pug
17 | - install pug via npm
18 | - use pug to parse exporters output to plain html
19 | */
20 | let renderedPug = exporter.domToPug(selRows)
21 | let renderedHTML = exporter.pugToHTML(renderedPug)
22 | console.log(renderedPug)
23 | console.log(renderedHTML)
24 |
25 | //Needs to be text/plain so pasting works in text editors etc. We could use custom data for internal copy & paste, but I think the system should be robus enough that copy & paste works with only plain html code.
26 | console.log(renderedHTML)
27 | e.clipboardData.setData('text/plain', renderedHTML)
28 | return
29 | }
30 |
31 | let sel = $('.sel')
32 | if (sel.length) {
33 | console.log(sel)
34 | //TODO: combine props to a single row and parse them to html
35 | }
36 | } else if (scope === 'editing') {
37 | //Let's only copy plain text when editing a prop
38 | let text = window.getSelection().toString()
39 | e.clipboardData.setData('text/plain', text)
40 | }
41 | }
42 |
43 | function beforeCut (e) {
44 | console.log('beforeCut')
45 | }
46 | function cut (e) {
47 | if (scope === '') {
48 | e.preventDefault()
49 | copy(e)
50 | del()
51 | } else if (scope === 'editing') {
52 | //TODO: cut needs to copy plain text in editing mode, no frigging rich text
53 | }
54 | }
55 |
56 | function paste (e) {
57 |
58 | //TODO: regular paste needs to prevent contenteditable from pasting styles, or clean up html after paste
59 |
60 | if (scope === '') {
61 |
62 | history.update()
63 |
64 | e.preventDefault()
65 |
66 | //TODO: this needs the exact same smarts for tab handling as drag & drop
67 |
68 | const cur = $('.cur')
69 | const clip = event.clipboardData.getData('text/plain')
70 | const data = importer.parseHTML(clip)
71 |
72 | if (data.type === 'props') {
73 | //Paste in like
74 | let dom = importer.renderProps(data.props)
75 | cur.after(dom)
76 | } else if (data.type === 'rows') {
77 | //Paste in like
dsa
78 | let dom = importer.renderRows(data.rows)
79 | //TODO: needs tab smarts here too
80 | cur.parent().after(dom)
81 | }
82 |
83 | cur.removeClass('cur')
84 | let newSel = $('.new')
85 | if (data.type === 'props') {
86 | newSel.last().addClass('cur')
87 | } else if (data.type === 'rows') {
88 | newSel.last().parent().children().first().addClass('cur')
89 | }
90 | newSel.removeClass('new')
91 | select(newSel)
92 |
93 | history.add()
94 |
95 | } else if (scope === 'editing') {
96 |
97 | e.preventDefault()
98 | let text = e.clipboardData.getData("text/plain")
99 | document.execCommand("insertHTML", false, text)
100 |
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/js/utils.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | //Ranui utils
4 | function getRowChildren(node) {
5 | //Finds rows that are more indented than the given row, until encounters a row with the same indentation or less. Does not select anything by itself, more of a utility function.
6 | let row = $(node)
7 | let tabs = parseInt(row.attr('tabs'))
8 | let children = $()
9 | row.nextAll().each((i, el)=>{
10 | let childTabs = parseInt($(el).attr('tabs'))
11 | if (childTabs > tabs) {
12 | children = children.add(el)
13 | } else {
14 | return false
15 | }
16 | })
17 | return children
18 | }
19 |
20 |
21 |
22 |
23 |
24 | //Misc js utility stuff that's not directly related to editing.
25 |
26 | jQuery.fn.selectText = function(){
27 | const element = this[0]
28 | if (element) {
29 | const selection = window.getSelection()
30 | const range = document.createRange()
31 | range.selectNodeContents(element)
32 | selection.removeAllRanges()
33 | selection.addRange(range)
34 | }
35 | }
36 | jQuery.fn.selectEnd = function(){
37 | const element = this[0]
38 | if (element) {
39 | const range = document.createRange()
40 | const selection = window.getSelection()
41 | range.selectNodeContents(element)
42 | range.collapse(false)
43 | selection.removeAllRanges()
44 | selection.addRange(range)
45 | }
46 | }
47 |
48 |
49 |
50 | //mod returns modifier keys in exclusive form, so you don't need to do e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey, just check if only shiftKey is pressed
51 | function modkeys (e, key) {
52 | let keys = {
53 | shift: e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey,
54 | alt: !e.shiftKey && e.altKey && !e.ctrlKey && !e.metaKey,
55 | ctrl: !e.shiftKey && !e.altKey && e.ctrlKey && !e.metaKey,
56 | cmd: !e.shiftKey && !e.altKey && !e.ctrlKey && e.metaKey,
57 | any: e.shiftKey || e.altKey || e.ctrlKey || e.metaKey,
58 | 'cmdShift': e.metaKey && e.shiftKey && !e.altKey && !e.ctrlKey,
59 | 'ctrlShift': e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey,
60 | 'ctrlCmd': e.metaKey && e.ctrlKey && !e.altKey && !e.shiftKey,
61 | }
62 | keys.none = !keys.any
63 | keys['shiftCmd'] = keys['cmdShift']
64 | keys['shiftCtrl'] = keys['ctrlShift']
65 | keys['ctrlCmd'] = keys['cmdCtrl']
66 |
67 | return keys
68 | //if (keys[key]) {return true}
69 | //else {return false}
70 | }
71 |
72 |
73 |
74 | //create a throttled instance of a function
75 | //throttledFunction = throttle(someFunctionHere)
76 | //use it
77 | //addEventListener(throttledFunction) or e=>throttled(e, arg, arg, etc)
78 | function throttle (fn, time, scope) {
79 | time = time || 250
80 | var last
81 | var deferTimer
82 |
83 | //create a scope with throttle, then return the throttled function that has access to the throttle scope, so it can set last & timer vars
84 | return function () {
85 | let context = scope || this
86 |
87 | let now = +new Date
88 | let args = arguments
89 |
90 | if (last && now < last + time) {
91 | clearTimeout(deferTimer)
92 | deferTimer = setTimeout(function () {
93 | last = now
94 | fn.apply(context, args)
95 | }, time)
96 | } else {
97 | last = now
98 | fn.apply(context, args)
99 | }
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/main.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const {
4 | app,
5 | shell,
6 | Menu,
7 | BrowserWindow,
8 | MenuItem,
9 | ipcMain,
10 | } = require('electron')
11 |
12 | const fileManager = require('./filemanager.js')
13 |
14 |
15 |
16 | //TODO: track frontmost window and send menu messages there?
17 |
18 | function createMenu() {
19 | const menuTemplate = [
20 | {
21 | label: app.name,
22 | submenu: [
23 | {
24 | role: 'about',
25 | },
26 | {
27 | type: 'separator'
28 | },
29 | {
30 | label: 'Services',
31 | role: 'services',
32 | submenu: []
33 | },
34 | {
35 | type: 'separator'
36 | },
37 | {
38 | role: 'hide'
39 | },
40 | {
41 | role: 'hideothers'
42 | },
43 | {
44 | role: 'unhide'
45 | },
46 | {
47 | type: 'separator'
48 | },
49 | {
50 | label: 'Quit',
51 | accelerator: 'Command+Q',
52 | click: app.quit
53 | },
54 | ]
55 | },
56 | {
57 | label: 'File',
58 | submenu: [
59 | {
60 | label: 'New',
61 | accelerator: 'Command+N',
62 | click: fileManager.newFile
63 | },
64 | {
65 | label: 'Open…',
66 | accelerator: 'Command+O',
67 | click: fileManager.open
68 | },
69 | {
70 | label: 'Save',
71 | accelerator: 'Command+S',
72 | click: fileManager.save //TODO: fix saveAs etc.
73 | },
74 | {
75 | label: 'Save As…',
76 | accelerator: 'Command+Shift+S',
77 | click: fileManager.saveAs
78 | },
79 | {
80 | label: 'Save All',
81 | accelerator: 'Command+Option+S',
82 | click: fileManager.saveAll
83 | },
84 | {
85 | type: 'separator'
86 | },
87 | {
88 | label: 'Close File',
89 | accelerator: 'Command+W',
90 | click: fileManager.close
91 | },
92 | ]
93 | },
94 | {
95 | label: 'Edit',
96 | submenu: [
97 | {
98 | label: 'Undo',
99 | accelerator: 'CmdOrCtrl+Z',
100 | //role: 'undo',
101 | click: (menuItem, browserWindow, event)=>{
102 | browserWindow.webContents.send('undo')
103 | }
104 | },
105 | {
106 | label: 'Redo',
107 | accelerator: 'CmdOrCtrl+Shift+Z',
108 | //role: 'redo',
109 | click: (menuItem, browserWindow, event)=>{
110 | browserWindow.webContents.send('redo')
111 | }
112 | },
113 | {
114 | type: 'separator'
115 | },
116 | {
117 | role: 'cut'
118 | },
119 | {
120 | role: 'copy'
121 | },
122 | {
123 | role: 'paste'
124 | },
125 | {
126 | role: 'delete'
127 | },
128 | {
129 | role: 'selectall'
130 | },
131 | {
132 | label: 'Speech',
133 | submenu: [
134 | {
135 | role: 'startspeaking'
136 | },
137 | {
138 | role: 'stopspeaking'
139 | }
140 | ]
141 | }
142 | ]
143 | },
144 | {
145 | label: 'View',
146 | submenu: [
147 | {
148 | role: 'reload'
149 | },
150 | {
151 | role: 'toggledevtools'
152 | },
153 | {
154 | type: 'separator'
155 | },
156 | {
157 | role: 'resetzoom'
158 | },
159 | {
160 | role: 'zoomin'
161 | },
162 | {
163 | role: 'zoomout'
164 | },
165 | {
166 | type: 'separator'
167 | },
168 | {
169 | role: 'togglefullscreen'
170 | },
171 | ]
172 | },
173 | {
174 | label: 'Window',
175 | role: 'window',
176 | submenu: [
177 | {
178 | role: 'minimize'
179 | },
180 | {
181 | role: 'zoom'
182 | },
183 | {
184 | type: 'separator'
185 | },
186 | {
187 | role: 'front'
188 | }
189 | ]
190 | },
191 | {
192 | label: 'Help',
193 | role: 'help',
194 | submenu: [
195 | {
196 | label: 'Ask @sakamies on Twitter',
197 | click: ()=>{
198 | shell.openExternal('https://twitter.com/sakamies')
199 | }
200 | },
201 | ]
202 | },
203 | ]
204 | const menu = Menu.buildFromTemplate(menuTemplate)
205 | Menu.setApplicationMenu(menu)
206 | }
207 |
208 | function createDockMenu() {
209 | const menuTemplate = [
210 | {
211 | label: 'New File',
212 | click: fileManager.newFile
213 | },
214 | ]
215 | const menu = Menu.buildFromTemplate(menuTemplate)
216 | app.dock.setMenu(menu)
217 | }
218 |
219 | app.on('ready', ()=>{
220 | createMenu()
221 | createDockMenu()
222 | fileManager.newFile()
223 | })
224 | app.on('window-all-closed', ()=>{
225 | if (process.platform !== 'darwin')
226 | app.quit()
227 | })
228 | app.on('activate', ()=> {
229 | if (BrowserWindow.getAllWindows().length === 0) {
230 | fileManager.newFile()
231 | }
232 | })
233 |
--------------------------------------------------------------------------------
/css/ranui.css:
--------------------------------------------------------------------------------
1 | /*
2 | $light: #f8f8f2;
3 | $lightgray: #828380;
4 | $gray1: #75715e;
5 | $gray2: #49483E;
6 | $gray3: #3e3d32;
7 | $dark: #272822;
8 | $red: #f92772;
9 | $purple: #be84ff;
10 | $green: #a6e22d;
11 | $yellow: #e6db74;
12 | $orange: #f6aa10;
13 | $blue: #66d9ef;
14 |
15 | $linkBlue: hsl(198, 49%, 46%);
16 | $hiGreen: hsl(70, 100%, 50%);
17 | */
18 |
19 | /*TODO: these styles have been made with a non retina screen, check with retina too! */
20 |
21 | * {
22 | box-sizing: border-box;
23 | margin: 0;
24 | padding: 0;
25 | font: inherit;
26 | line-height: inherit;
27 | color: inherit;
28 | //-webkit-font-smoothing: antialiased;
29 | -webkit-user-select: none;
30 | }
31 |
32 | :root {
33 | font-size: 62.5%;
34 | font-size: 10px;
35 | }
36 |
37 | body {
38 | font-family: monospace;
39 | font-family: "Courier";
40 | font-family: "Menlo";
41 | font-size: 1.5rem;
42 | line-height: 2.0rem;
43 | white-space: nowrap;
44 | color: #f8f8f2;
45 | background-color: #272822;
46 | -webkit-text-size-adjust: 100%;
47 | }
48 |
49 | ::selection {
50 | background-color: hsla(40, 3.5%, 62%, 1);
51 | }
52 | :focus {
53 | outline: none;
54 | }
55 |
56 | doc, row {
57 | display: block;
58 | }
59 | tag, prop, val, txt {
60 | display: inline;
61 | line-height: 2.0rem;
62 | height: 2.0rem;
63 | white-space: pre-wrap;
64 | padding: .1rem .5ch;
65 | }
66 |
67 | doc {
68 | position: relative;
69 | padding: 0 0;
70 | background-image: linear-gradient(to left, #49483E 1px, transparent 1px);
71 | background-position: left -.5ch top;
72 | background-repeat: repeat-x;
73 | background-size: 2ch 100%;
74 | }
75 | row {
76 | background-color: #272822;
77 | background-origin: content-box;
78 | background-clip: content-box;
79 | background-position: left top;
80 | background-repeat: no-repeat;
81 | background-size: 1px 100%;
82 | }
83 |
84 | tag {
85 | color: #f92772;
86 | }
87 | prop {
88 | color: #a6e22d;
89 | }
90 | val {
91 | color: #e6db74;
92 | }
93 | prop + val::before {
94 | content: ':';
95 | font: inherit;
96 | position: absolute;
97 | margin-left: calc(-1ch);
98 | color: #a6e22d;
99 | color: white;
100 | }
101 | txt ~ * {
102 | border-left: 3px solid #f92772;
103 | }
104 |
105 |
106 | /* TODO: Lol crazy indentation with css, need to make this with js or somehow smarter */
107 | [tabs] {margin-left: 22ch;}
108 | [tabs="0"] {margin-left: 0ch}
109 | [tabs="1"] {margin-left: 2ch}
110 | [tabs="2"] {margin-left: 4ch}
111 | [tabs="3"] {margin-left: 6ch}
112 | [tabs="4"] {margin-left: 8ch}
113 | [tabs="5"] {margin-left: 10ch}
114 | [tabs="6"] {margin-left: 12ch}
115 | [tabs="7"] {margin-left: 14ch}
116 | [tabs="8"] {margin-left: 16ch}
117 | [tabs="9"] {margin-left: 18ch}
118 | [tabs="10"] {margin-left: 20ch}
119 |
120 | /* TODO: could use requestIdleCallback for checking and marking errors, kinda like os level spell check does */
121 | [tabs="0"] + :-webkit-any([tabs="2"],[tabs="3"],[tabs="4"],[tabs="5"],[tabs="6"],[tabs="7"],[tabs="8"],[tabs="9"],[tabs="10"]) > :first-child,
122 | [tabs="1"] + :-webkit-any([tabs="3"],[tabs="4"],[tabs="5"],[tabs="6"],[tabs="7"],[tabs="8"],[tabs="9"],[tabs="10"]) > :first-child,
123 | [tabs="2"] + :-webkit-any([tabs="4"],[tabs="5"],[tabs="6"],[tabs="7"],[tabs="8"],[tabs="9"],[tabs="10"]) > :first-child,
124 | [tabs="3"] + :-webkit-any([tabs="5"],[tabs="6"],[tabs="7"],[tabs="8"],[tabs="9"],[tabs="10"]) > :first-child,
125 | [tabs="4"] + :-webkit-any([tabs="6"],[tabs="7"],[tabs="8"],[tabs="9"],[tabs="10"]) > :first-child,
126 | [tabs="5"] + :-webkit-any([tabs="7"],[tabs="8"],[tabs="9"],[tabs="10"]) > :first-child,
127 | [tabs="6"] + :-webkit-any([tabs="8"],[tabs="9"],[tabs="10"]) > :first-child,
128 | [tabs="7"] + :-webkit-any([tabs="9"],[tabs="10"]) > :first-child,
129 | [tabs="8"] + :-webkit-any([tabs="10"]) > :first-child {
130 | border-left: 3px dotted red;
131 | }
132 | [type="txt"][tabs="0"] + :-webkit-any([tabs="2"],[tabs="2"],[tabs="3"],[tabs="4"],[tabs="5"],[tabs="6"],[tabs="7"],[tabs="8"],[tabs="9"],[tabs="10"]) > :first-child,
133 | [type="txt"][tabs="1"] + :-webkit-any([tabs="2"],[tabs="3"],[tabs="4"],[tabs="5"],[tabs="6"],[tabs="7"],[tabs="8"],[tabs="9"],[tabs="10"]) > :first-child,
134 | [type="txt"][tabs="2"] + :-webkit-any([tabs="3"],[tabs="4"],[tabs="5"],[tabs="6"],[tabs="7"],[tabs="8"],[tabs="9"],[tabs="10"]) > :first-child,
135 | [type="txt"][tabs="3"] + :-webkit-any([tabs="4"],[tabs="5"],[tabs="6"],[tabs="7"],[tabs="8"],[tabs="9"],[tabs="10"]) > :first-child,
136 | [type="txt"][tabs="4"] + :-webkit-any([tabs="5"],[tabs="6"],[tabs="7"],[tabs="8"],[tabs="9"],[tabs="10"]) > :first-child,
137 | [type="txt"][tabs="5"] + :-webkit-any([tabs="6"],[tabs="7"],[tabs="8"],[tabs="9"],[tabs="10"]) > :first-child,
138 | [type="txt"][tabs="6"] + :-webkit-any([tabs="7"],[tabs="8"],[tabs="9"],[tabs="10"]) > :first-child,
139 | [type="txt"][tabs="7"] + :-webkit-any([tabs="8"],[tabs="9"],[tabs="10"]) > :first-child,
140 | [type="txt"][tabs="8"] + :-webkit-any([tabs="9"],[tabs="10"]) > :first-child {
141 | outline: 1px dotted red;
142 | }
143 |
144 | .com {
145 | font-style: italic;
146 | }
147 | .com > *,
148 | .com > *::before {
149 | /*TODO: comments could mix the original color of the tag with this gray 50/50, this needs sass, so set it up*/
150 | color: #75715e;
151 | }
152 |
153 | .sel {
154 | background-color: #49483E;
155 | //box-shadow: inset 0 -2px 0 0 hsla(200, 0%, 50%, .5);
156 | }
157 | .cur {
158 | position: relative;
159 | box-shadow: inset 0 -2px 0 0 hsla(200, 100%, 50%, 1);
160 | }
161 | .hilite {
162 | background-color: #49483E;
163 | //box-shadow: inset 0 -2px 0 0 hsla(200, 0%, 50%, .5);
164 | }
165 | .folded::after {
166 | content: '…';
167 | }
168 | .hidden {
169 | display: none;
170 | }
171 |
172 |
173 |
174 | [contenteditable="true"],
175 | .clone {
176 | /*TODO Editable styling needs work, you need to be able to see what type of prop you're editing, can't be just plain black on white. But still needs to be obvious that now you're editing text. Editing cursor appearance would be ace, but sometimes chrome just doesn't even render the cursor in styled contenteditables. */
177 | background-color: transparent;
178 | }
179 |
180 | .dragsource,
181 | .dragsource::before {
182 | color: transparent;
183 | }
184 | .dragsource > *,
185 | .dragsource > *::before {
186 | color: transparent;
187 | }
188 |
189 | .dragghost {
190 | position: absolute;
191 | z-index: 200;
192 | display: none;
193 | pointer-events: none;
194 | box-shadow: 0 2px 8px -2px rgba(0,0,0,.5);
195 | }
196 |
197 | /*TODO: drop point indicator should be a div that travels the page and gets rendered according to droptarget clientrect */
198 | .dropbefore,
199 | .dropafter {
200 | position: relative;
201 | //background-color: hsla(200, 100%, 50%, .33);
202 | }
203 | .dropbefore::after,
204 | .dropafter::after {
205 | content: '';
206 | position: absolute;
207 | display: block;
208 | z-index: 100;
209 | top: .5rem;
210 | left: -.5ch;
211 | width: 1ch;
212 | height: 1.0rem;
213 | border-radius: 1ch;
214 | background-color: hsla(200, 100%, 50%, 1);
215 | }
216 | .dropafter::after {
217 | left: auto;
218 | right: -.5ch;
219 | }
220 | row.dropbefore::after,
221 | row.dropafter::after {
222 | top: -2px;
223 | left: auto;
224 | right: auto;
225 | width: 100%;
226 | height: 4px;
227 | }
228 | row.dropafter::after {
229 | top: auto;
230 | bottom: -2px;
231 | }
232 |
--------------------------------------------------------------------------------
/js/parseHTML.js:
--------------------------------------------------------------------------------
1 | module.exports = parseHTML
2 |
3 |
4 | function parseHTML (htmlString) {
5 |
6 | //Check if string is like and if it is, treat is as a bunch of attributes, although browsers would create an element or something crazy.
7 | if (htmlString.match(/^<\w*=".*">$/)) {
8 | let props = []
9 | let attrs = htmlString.slice(1, -2).split('" ') //Remove < and > from start & end and split string at attribute boundaries. Also props always need a valuve inside quotes, even if the value is empty. Quotes should always be encoded insite attributes. This is not really very robust, but works for now.
10 | console.log('split attrs', attrs)
11 | for (let i = 0; i < attrs.length; i++) {
12 | let attr = attrs[i]
13 | let pv = attr.split('="')
14 | props.push({
15 | type: 'prop',
16 | text: pv[0]
17 | })
18 | if (pv[1]) {
19 | props.push({
20 | type: 'val',
21 | text: pv[1]
22 | })
23 | }
24 | }
25 | console.log(props)
26 | return {
27 | type: 'props',
28 | props: props
29 | }
30 | }
31 |
32 | else if (htmlString) {
33 | let rows = HTMLstring(htmlString)
34 | let doc = {
35 | type: 'rows',
36 | rows: rows
37 | }
38 | return doc
39 | }
40 |
41 | else {
42 | return []
43 | }
44 | }
45 |
46 | //All these processing methods return an array of rows
47 | function HTMLstring (htmlString, depth, commented) {
48 | if (!commented) {commented = false}
49 | let rows = []
50 | if (!depth) {
51 | depth = 0
52 | }
53 | if (htmlString.indexOf('text", so there's no whitespace after a tag. Since the text will be on its own row that no whitespace situation should probably be noted somehow, maybe with the >< whitespace eating crocodiles syntax. Also if there's some text and then a line break, should the line break + whitespace be cleaned up?
145 | //TODO: handle whitespace inside pre, code, textarea (etc?) elements somehow. Check ['pre', 'code'].indexOf($().parents().nodeName)) or something
146 | /*TODO:
147 | - split text nodes on any '\n', so each row is a row in the doc too
148 | - trim rows and somehow smartly add tabs, impossibble to do totally reliably, with all the mixes space & tab combos and such
149 | */
150 | let textContent = node.textContent
151 | let texts = []
152 | let rows = []
153 |
154 | if (textContent.match(/^\s*\n\s*$/)) { //Skip text that's probably only whitespace for formatting the html. TODO: a line break will add a text node that will affect inline(-block) element rendering, so this needs to be pretty smart
155 | return false
156 | } else if (textContent.match(/^\s+$/)) {
157 | texts = [textContent]
158 | } else {
159 | texts = textContent.trim().split('\n')
160 | }
161 | //TODO: text.match(/^\s+$/) !?!?
162 |
163 | for (let i = 0; i < texts.length; i++) {
164 | let props = []
165 | let row = {}
166 | props.push({
167 | type: 'txt',
168 | text: texts[i], //TODO: trim away only as much spaces as depth needs from the front of the string
169 | })
170 | row = {
171 | type: 'txt',
172 | commented: commented,
173 | tabs: depth,
174 | props: props
175 | }
176 | rows.push(row)
177 | }
178 |
179 | return rows
180 | }
181 |
182 | function commentNode (node, depth, commented) {
183 | //Contents of commentnode get parsed as html, so they're not just some blubber of text, but evetyrhint that gets parsed inside a comment node will be marked as commented out rows
184 | let rows = []
185 | commented = true
186 | rows = rows.concat(HTMLstring(node.textContent, depth, commented))
187 | return rows
188 | }
189 |
190 | function doctypeNode (node, depth, commented) {
191 | return [{
192 | commented: commented,
193 | tabs: depth,
194 | props: [{
195 | type: 'tag',
196 | text: '!doctype'
197 | }]
198 | }]
199 | }
200 |
201 | function documentNode (node, depth, commented) {
202 | let rows = []
203 | if (node.childNodes.length != 0) {
204 | for (let childNum = 0; childNum < node.childNodes.length; childNum++) {
205 | rows = rows.concat(domNode(node.childNodes[childNum], depth, commented))
206 | }
207 | }
208 | return rows
209 | }
210 |
--------------------------------------------------------------------------------
/js/mouse.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | let dragTimer
4 | let mouseDownEvent = null
5 | let dragGhost = $('
')
6 | let dropTarget = null
7 | let dragMode = ''
8 | let mouseStart = ''
9 |
10 | function mouseDown(e) {
11 | //Allow mouse to function according to platform defaults when editing text, also this way there's no need to worry about editing mode for the rest of this function & other mouse events.
12 | if (e.target.isContentEditable === true) {
13 | return true
14 | } else if (scope === 'editing') {
15 | commitEdit()
16 | }
17 |
18 | //e.preventDefault()
19 | let target = $(e.target)
20 | mouseDownEvent = e
21 | scope = 'paintselection'
22 |
23 | if (!target.hasClass('sel') && !target.hasClass('hilite')) {
24 | selTarget(e)
25 | }
26 |
27 | dragTimer = setTimeout(_=>dragStart(e), 220)
28 | }
29 |
30 | function dragStart(e) {
31 | history.update()
32 | scope = 'dragging'
33 |
34 | //Format what's being dragged, so the drag ghost exactly reflects what will be dropped.
35 | //Dragging gathers all selected items and puts them in flat rows.
36 | let dragSourceRows = $('.hilite')
37 | //TODO: this could be made more efficient, seems wasteful
38 | dragSourceRows = dragSourceRows.add($('.hilite.folded').nextUntil('row:not(.hidden)')) //If you fold a row and drag it, its children will come with the drag
39 | let dragSourceProps = $('row:not(.hilite) .sel')
40 | let dragPayloadRows = dragSourceRows.clone()
41 | let dragPayloadProps = dragSourceProps.clone()
42 | dragSourceRows.addClass('dragsource')
43 | dragSourceProps.addClass('dragsource')
44 |
45 | //TODO: This allows nonsensical prop/row combinations, combining props with a txt row, but css will mark it as an error and the user needs to correct it. Should make this foolproof somehow.
46 | if (dragPayloadRows.length) {
47 | dragMode += ':rows'
48 |
49 | /*TODO:
50 | 1. Split continuous ranges of selected rows to groups.
51 | 2. Go through each group
52 | 1. Get the children of the topmost row, split to a group
53 | 2. If there's rows left, repeat 1.
54 | 3. Unindent each group enough so that the upmost row is at tab 0
55 | */
56 |
57 | dragPayloadRows.children().first().after(dragPayloadProps)
58 | dragGhost.append(dragPayloadRows)
59 | } else if (dragPayloadProps.length) {
60 | dragMode += ':props'
61 | dragGhost.append(dragPayloadProps)
62 | }
63 |
64 | dragGhost.css({
65 | 'display': 'inline-block',
66 | 'left': e.pageX + 'px',
67 | 'top': e.pageY + 'px',
68 | })
69 | $('doc').after(dragGhost)
70 | }
71 |
72 | function mouseMove(e) {
73 | clearTimeout(dragTimer)
74 | if (scope === '') {
75 | //TODO: render that left side element+children selection highlight here. There should also be a generic 'render' function or something like that, that renders all the necessary effects, like a highlight for the selected element to the vertical lines that show indentation and stuff.
76 | } else if (scope === 'paintselection') {
77 | //Paint selection if mouse was moved before drag was initiated
78 | selTarget(e, ':add')
79 | } else if (scope === 'dragging') {
80 | //Make dragGhost follow mouse
81 | dragGhost.css({
82 | 'left': e.pageX + 'px',
83 | 'top': e.pageY + 'px',
84 | })
85 |
86 |
87 | if (dropTarget) {
88 | dropTarget.removeClass('dropbefore dropafter')
89 | dropTarget = null
90 | }
91 | let target = $(e.target)
92 |
93 | //What a crazy contraption, is there a simpler way to do this?
94 | if (dragMode.includes(':props')) {
95 | if (['PROP','VAL'].includes(e.target.tagName)) {
96 | dropTarget = target
97 | let hitbox = e.target.getBoundingClientRect()
98 | let centerX = hitbox.left + hitbox.width / 2
99 | let centerY = hitbox.top + hitbox.height / 2
100 | if (e.clientX < centerX) {
101 | dropTarget.addClass('dropbefore')
102 | } else {
103 | dropTarget.addClass('dropafter')
104 | }
105 | } else if (e.target.tagName === 'TAG') {
106 | dropTarget = target
107 | dropTarget.addClass('dropafter')
108 | } else if (e.target.tagName === 'ROW' && target.attr('type') !== 'txt') {
109 | //This if case needs to be here in case the props are dragged more left or right than any props, so the dragmode is props mut cursor is on row
110 | let children = target.children()
111 |
112 | let first = children.eq(0)
113 | let hitFirst = first[0].getBoundingClientRect()
114 | let hitLeft = hitFirst.left + hitFirst.width
115 |
116 | let last = children.last()
117 | let hitLast = last[0].getBoundingClientRect()
118 | let hitRight = hitLast.left + hitLast.width
119 |
120 | if (e.clientX < hitLeft) {
121 | dropTarget = first
122 | dropTarget.addClass('dropafter')
123 | } else if (e.clientX > hitRight) {
124 | dropTarget = last
125 | dropTarget.addClass('dropafter')
126 | }
127 | } else {
128 | }
129 | }
130 | else if (dragMode.includes(':rows')) {
131 | let hitbox = e.target.getBoundingClientRect()
132 | let centerY = hitbox.top + hitbox.height / 2
133 | let toTop = false
134 | let toBottom = false
135 |
136 | if (['TAG','PROP','VAL','TXT'].includes(e.target.tagName)) {
137 | dropTarget = target.parent()
138 | } else if (e.target.tagName === 'ROW') {
139 | dropTarget = target
140 | }
141 |
142 | if (dropTarget && e.clientY < centerY) {
143 | dropTarget.addClass('dropbefore')
144 | } else if (dropTarget && e.clientY >= centerY) {
145 | dropTarget.addClass('dropafter')
146 | }
147 |
148 | }
149 | }
150 | }
151 |
152 | function mouseUp(e) {
153 | //e.preventDefault()
154 | clearTimeout(dragTimer)
155 |
156 | //TODO: check for shift/alt/ctrl/cmd, there should be a key that lets you clone elements
157 | let dragSource = $('.dragsource')
158 |
159 | if (scope === 'dragging' && dropTarget) {
160 | let dragPayload = dragGhost.children()
161 | //Set tabs according to droptarget tabs. This means that dragging does not preserve hierarchy in any way. It probably should preserve hierarchies where there's only an element and its children selected
162 | //dragPayload.attr('tabs', dropTarget.attr('tabs'))
163 | if (dropTarget.hasClass('dropafter')) {
164 | dropTarget.after(dragPayload)
165 | } else if (dropTarget.hasClass('dropbefore')) {
166 | dropTarget.before(dragPayload)
167 | }
168 | dragSource.remove()
169 |
170 | //At this poin the operation happened, add entry to history. The rest of mouseup is just cleanup
171 | history.add()
172 | } else if (scope === 'paintselection' && mouseDownEvent.screenX === e.screenX && mouseDownEvent.screenY === e.screenY) {
173 | //If you just click on an item and don't do a lasso selection or drag, then select the item
174 | selTarget(e)
175 | }
176 |
177 | if (dropTarget) {dropTarget.removeClass('dropbefore dropafter')}
178 | dropTarget = null
179 | if (dragSource) {dragSource.removeClass('dragsource')}
180 | dragGhost.css('display', 'none').empty()
181 |
182 | dragMode = ''
183 | mouseDownEvent = null
184 | if (scope === 'dragging' || scope === 'paintselection') {
185 | scope = '' //only reset scopes we have set in mouse.js, leave any other scopes alone
186 | }
187 | }
188 |
189 |
190 | function cancelDrag(e) {
191 | e.preventDefault()
192 | if (scope === 'dragging' || scope === 'paintselection') {
193 | scope = '' //only reset scopes we have set in mouse.js, leave any other scopes alone
194 | }
195 | mouseUp(e)
196 | }
197 |
--------------------------------------------------------------------------------
/js/keydown.js:
--------------------------------------------------------------------------------
1 | //TODO: This is so horrible to read, should make a nicer abstractions for input handling. But all input handling libs I've found have been lacking.
2 |
3 | //TODO: some of these probably belong in app menus and need to be listened to by ipcRenderer.on('whatever', e=>{})
4 |
5 | function keydown(e) {
6 | console.log(e.key, e.code)
7 | //mod returns modifier keys in exclusive form, so you don't need to do e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey, just check if only shiftKey is pressed
8 | let mod = modkeys(e)
9 |
10 | //Selection
11 | if (scope === '') {
12 | if (mod.none && e.key === 'ArrowUp') {
13 | e.preventDefault()
14 | selRow('up')
15 | return
16 | }
17 | else if (mod.none && e.key === 'ArrowDown') {
18 | e.preventDefault()
19 | selRow('down')
20 | return
21 | }
22 | else if (mod.none && e.key === 'ArrowLeft') {
23 | e.preventDefault()
24 | selCol('left')
25 | return
26 | }
27 | else if (mod.none && e.key === 'ArrowRight') {
28 | e.preventDefault()
29 | selCol('right')
30 | return
31 | }
32 | else if (mod.shift && e.key === 'ArrowUp') {
33 | e.preventDefault()
34 | selRow('up:add')
35 | return
36 | }
37 | else if (mod.shift && e.key === 'ArrowDown') {
38 | e.preventDefault()
39 | selRow('down:add')
40 | return
41 | }
42 | else if (mod.shift && e.key === 'ArrowLeft') {
43 | e.preventDefault()
44 | selCol('left:add')
45 | return
46 | }
47 | else if (mod.shift && e.key === 'ArrowRight') {
48 | e.preventDefault()
49 | selCol('right:add')
50 | return
51 | }
52 | else if (mod.cmd && e.key === 'a') {
53 | e.preventDefault()
54 | selAll(e)
55 | return
56 | }
57 | else if (mod.cmd && e.key === 'd') {
58 | e.preventDefault()
59 | selSimilar(e)
60 | return
61 | }
62 | else if (mod.none && e.key === 'Escape') {
63 | e.preventDefault()
64 | selEscape()
65 | return
66 | }
67 | else if (mod.none && e.key === '-') {
68 | e.preventDefault()
69 | fold(':fold')
70 | return
71 | }
72 | else if (mod.none && e.key === '+') {
73 | e.preventDefault()
74 | fold(':unfold')
75 | return
76 | }
77 | }
78 |
79 | //Creating stuff
80 | if (scope === '') {
81 | if (mod.none && e.key.match(/^[a-z]$/)) {
82 | e.preventDefault()
83 | //TODO: should parse the letter here and send just that to createRow, not the whole event in these create functions
84 | createRow(e, ':tag')
85 | return
86 | }
87 | else if ((mod.none || mod.shift) && e.key.match(/^[A-Z]$/)) {
88 | e.preventDefault()
89 | createRow(e, ':txt')
90 | return
91 | }
92 | else if (mod.shift && e.key === 'Enter') {
93 | e.preventDefault()
94 | //TODO: shift+enter should add a line break when editing a txt
95 | createRow(e, ':txt')
96 | return
97 | }
98 | else if (mod.cmd && e.key === 'Enter') {
99 | e.preventDefault()
100 | //TODO: cmd+enter should work in any scope
101 | createRow(e, ':tag', 'div')
102 | return
103 | }
104 | else if (mod.none && e.key === ' ') {
105 | e.preventDefault()
106 | createProp(e)
107 | return
108 | }
109 | else if (e.key === ',') {
110 | e.preventDefault()
111 | createProp(e, ':prop')
112 | return
113 | }
114 | else if (e.key === ':' || e.key === '=') {
115 | e.preventDefault()
116 | createProp(e, ':val')
117 | return
118 | }
119 | else if (e.key === '#') {
120 | e.preventDefault()
121 | createProp(e, ':id')
122 | return
123 | }
124 | else if (e.key === '.') {
125 | e.preventDefault()
126 | createProp(e, ':class')
127 | return
128 | }
129 | }
130 |
131 | //Edit actions while in root scope
132 | if (scope === '') {
133 | if (mod.none && e.key === 'Enter') {
134 | history.update()
135 | e.preventDefault()
136 | startEdit()
137 | return
138 | }
139 | else if (mod.none && e.key === 'Backspace') {
140 | e.preventDefault()
141 | del(':backward')
142 | return
143 | }
144 | else if (mod.none && e.key === 'Delete') {
145 | e.preventDefault()
146 | del(':forward')
147 | return
148 | }
149 | else if (mod.cmdShift && e.code === 'KeyD') { //Use KeyD so there's no confusion because of shift modifying the letter that's output
150 | //TODO: implement duplicate
151 | e.preventDefault()
152 | duplicate()
153 | return
154 | }
155 | else if (mod.none && e.key === 'Tab') {
156 | e.preventDefault()
157 | tab(1)
158 | return
159 | }
160 | else if (mod.shift && e.key === 'Tab') {
161 | e.preventDefault()
162 | tab(-1)
163 | return
164 | }
165 | else if (e.metaKey && e.key === '/') { //Check for emetakey instead of mod function because / could come through modifiers on some key layouts, like Scandinavian ones for example.
166 | e.preventDefault()
167 | comment()
168 | return
169 | }
170 | else if (mod.ctrl && e.key === 'ArrowUp') {
171 | e.preventDefault()
172 | moveRow(':up')
173 | return
174 | }
175 | else if (mod.ctrl && e.key === 'ArrowDown') {
176 | e.preventDefault()
177 | moveRow(':down')
178 | return
179 | }
180 | else if (mod.ctrl && e.key === 'ArrowLeft') {
181 | e.preventDefault()
182 | moveCol(':left')
183 | return
184 | }
185 | else if (mod.ctrl && e.key === 'ArrowRight') {
186 | e.preventDefault()
187 | moveCol(':right')
188 | return
189 | }
190 | }
191 |
192 | //Edit actions while editing an item
193 | if (scope === 'editing') {
194 | let target = $('[contenteditable="true"]')
195 |
196 | if (mod.none && e.key === 'Enter' || e.key === 'Escape') {
197 | e.preventDefault()
198 | commitEdit()
199 | return
200 | }
201 | else if (mod.none && e.key === 'Backspace' || e.key === 'Delete') {
202 | autofill.prevent()
203 | return
204 | }
205 | else if (mod.none && e.key === 'Tab') {
206 | e.preventDefault()
207 | commitEdit()
208 | //Pressing tab to indent while editing felt way too fiddly, fought with muscle memory, so pressing tab is like autocompletion in the terminal or text editor, it just accepts whatever's in the input box.
209 | return
210 | }
211 | else if (mod.shift && e.key === 'Tab') {
212 | e.preventDefault()
213 | commitEdit()
214 | return
215 | }
216 | //Make new props when pressing keys that make sense. Like, you'd expect that if you type `div` + space, that stuff after that would be an attribute name, so we make add an attribute when you press space when editing a tag. This becomes troublesome when the visualised syntax clashes with html validity. HTML allows : * and stuff in attribute names. Pressing : inside an attribute name must allow you to keep typing, because svg is a common case where you use some namespacing.
217 | //TODO: That `:` is dependant on how the editor visualizes attrs, it should be `=` if the editor shows attr=val and `:` if its' attr:val and so on. So there should be some viz/style config that determines how the editor looks and how some shortcuts behave. Maybe : should be = instead, because that would make more sense for html.
218 | else if (target[0].tagName === 'TAG' && e.code === 'Space') {
219 | e.preventDefault()
220 | commitEdit()
221 | createProp(e)
222 | } else if (target[0].tagName === 'PROP' && (e.code === 'Space' || e.key === ':' || e.key === '=')) {
223 | e.preventDefault()
224 | commitEdit()
225 | createProp(e)
226 | }
227 | }
228 |
229 | //Drag & drop
230 | if (scope === 'dragging') {
231 | if (e.key === 'Escape') {
232 | e.preventDefault()
233 | cancelDrag(e)
234 | return
235 | }
236 | }
237 | }
238 |
--------------------------------------------------------------------------------
/js/selection.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | let col = 0
4 |
5 |
6 | function select(to, opts) {
7 |
8 | opts = opts || ''
9 | //If selection is not additive, remove sel class from everything that's not cursor
10 | if (opts.includes(':add')) {
11 | } else {
12 | $('.sel:not(.cur)').removeClass('sel')
13 | }
14 | if (opts.includes(':children')) {
15 | to = to.add(getRowChildren(to.parent('row')).children(':first-child'))
16 | }
17 | to.addClass('sel')
18 |
19 |
20 | //Tag & txt props are proxies for their row, so when they are selected, highlight entire row
21 | $('.hilite').removeClass('hilite')
22 | $('tag.sel, txt.sel').parent().addClass('hilite')
23 |
24 | //When you select a prop, select its val too, so you can't make operations for prop alone, because if the value doesn't have a prop before it, it makes no sense as html.
25 | $('prop.sel').nextUntil(':not(val)').addClass('sel')
26 |
27 | //When you select a folded row, all its children need to be selected too, so let's get any folded stuff from the new selection and select them too. This is so tabbing, moving, dragging etc work like they're supposed to. Chould use `select(to, ':children:add')` here, but if the indentation is not pristine, that could produce the wrong result.
28 | $('.hilite.folded')
29 | .nextUntil(':not(.hidden)').addClass('hilite')
30 | .children('tag, txt').addClass('sel')
31 |
32 | //TODO: check that any functions that modify or select stuff use this function to set selection (instead of modifying `.sel` classes directly), so I can be sure that hidden rows are always selected if the folded row is selected
33 | //TODO: all edit operations need to be aware of hidden rows for folding to work, maybe I should disable folding for a while until I get a handle on how it should work
34 | //TODO: createRow either unfold a row just before adding a row, or only create siblings for folded rows.
35 | //TODO: I could handle all these cases if I had a generic function to get an element reference, so when doing move operations etc, I'd get the folded row and its children and move the whole bunch
36 |
37 | }
38 |
39 |
40 | function selTarget (e, opts) {
41 | //This is only invoved from mouse events for now, so this can assume e is a real mouse event
42 |
43 | opts = opts || ''
44 | if (e && e.preventDefault) {e.preventDefault()}
45 |
46 | let cursors = $('.cur')
47 | let target = $(e.target)
48 | let newCur = $()
49 |
50 | if (target.parent('row').length) {
51 | newCur = target
52 | } else if (e.target.tagName === 'ROW') {
53 | newCur = target.children().first()
54 | if (e.layerX < newCur.position().left) {
55 | //TODO: clicking near a vertical line that shows indentation depth should select the row where that vertical line originates from, and that rows children. Select children of row if you click on the left side of the tag. This should extend all the way down for the whole element, but row level is fine for now.
56 | console.log(e)
57 | opts += ':children'
58 | }
59 | } else {
60 | newCur = $('doc').children().last().children().last()
61 | }
62 |
63 | if (e.shiftKey) {opts += ':add'} //TODO: Shift should select a range and Cmd should toggle selection on individual rows?
64 | //TODO: move modkey checking and option adding from here to event listeners, so shift+click runs selTarget(e, ':add')
65 | if (!e.altKey) {cursors.removeClass('cur')} //You can drop multiple cursors by pressing alt TODO: move this to opts via mouse.js
66 | newCur.addClass('cur')
67 | select(newCur, opts)
68 | }
69 |
70 |
71 | //Select up/down, additive selects up/down by nearest col it finds
72 | function selRow (act) {
73 | let cursors = $('.cur')
74 | let cursor = cursors.first()
75 | let newCurs = $()
76 |
77 | //Track column based on the first cursor, because multiple cursors always collapse to the first cursor
78 | //TODO: if there's only one cursor, use col to track farthest right position like text editors do. I could do that for every cursor by making col into an array, but not sure that's the right thing to do.
79 | col = Math.max(col, cursor.parent().children().index(cursor))
80 |
81 | let up = act.includes('up')
82 | let down = act.includes('down')
83 | let end = act.includes('end') //TODO: implement end
84 |
85 | //TODO: add simple cases for up & down if there's no selection. The app should take care that there's always some element selected, but might be good to have just in case
86 |
87 |
88 | cursors.each(function(index, el) {
89 | let cursor = $(el)
90 | let row = cursor.parent()
91 | let props = row.children()
92 | let cursorCol = props.index(cursor)
93 | let newRow
94 | let newCur
95 | if (up) {newRow = row.prevAll(':not(.hidden)').first()} //Skip children of folded rows
96 | if (down) {newRow = row.nextAll(':not(.hidden)').first()}
97 | let newProps = props
98 | if (newRow.length) {newProps = newRow.children()}
99 | if (act.includes('add')) {
100 | //Additive up & down selection selects only rows, so clear selection on row and select first prop
101 | row.find('.sel').removeClass('sel')
102 | row.find('tag, txt').addClass('sel')
103 | newCur = newProps.first()
104 | } else if (newProps.length - 1 >= cursorCol) { //Because col is zero based, ugh
105 | newCur = newProps.eq(cursorCol)
106 | } else if (newProps.length > 0) {
107 | newCur = newProps.first()
108 | }
109 | newCurs = newCurs.add(newCur)
110 | })
111 | cursors.removeClass('cur')
112 | newCurs.addClass('cur')
113 | select(newCurs, act) //Pass options to select functions, actual additive selection happens there
114 | }
115 |
116 |
117 | function selCol (act) {
118 | let cursors = $('.cur')
119 | let cursor = $('cur').first()
120 | let newCurs = $()
121 |
122 | //Track column based on the first cursor, because multiple cursors always collapse to the first cursor
123 | col = cursor.parent().children().index(cursor)
124 |
125 | let left = act.includes('left')
126 | let right = act.includes('right')
127 | let end = act.includes('end') //TODO: implement end
128 |
129 | //TODO: add simple cases for left & right if there's no selection. The app actions should really always result in a selection, and the first tag should be selected on document open, but it might be good to have just in case
130 |
131 | cursors.each(function(index, el) {
132 | let cursor = $(el)
133 | let newCur
134 | if (left) {newCur = cursor.prev()}
135 | if (right) {newCur = cursor.next()}
136 |
137 | //These following options just didn't feel right, so going left & going right stays on the same row
138 | //if (!newCur.length) {
139 | //Going left could select the last item of the previous row, like in text editor
140 | //if (left) {newCur = cursor.parent().prev().children().first()}
141 | //Going left could select the closest parent of the row
142 | // if (left) {
143 | // let cursorRow = cursor.parent()
144 | // let cursorTabs = cursorRow.attr('tabs')
145 | // cursorRow.prevAll().each(function(i) {
146 | // let prevRow = $(this)
147 | // let prevTabs = parseInt(prevRow.attr('tabs'))
148 | // if (prevTabs < cursorTabs) {
149 | // newCur = prevRow.children().first()
150 | // return false
151 | // }
152 | // })
153 | // }
154 | //Going right could select the first item of the next row, or select the first child of the row
155 | //if (right) {newCur = cursor.parent().next().children().first()}
156 | //}
157 |
158 | if (!newCur.length) {
159 | newCur = cursor
160 | }
161 | newCurs = newCurs.add(newCur)
162 | })
163 |
164 | cursors.removeClass('cur')
165 | newCurs.addClass('cur')
166 | select(newCurs, act)
167 | }
168 |
169 |
170 | function selEscape () {
171 | let cursors = $('.cur')
172 | let cursor = cursors.first()
173 | let newCur
174 |
175 | if (cursors.length > 1) {
176 | //Collapse multiple cursors
177 | newCur = cursor
178 | } else if (['PROP', 'VAL'].includes(cursor[0].tagName)) {
179 | //If cursor is not on beginning of row (tag), move it there
180 | newCur = cursor.parent().children().first()
181 | } else {
182 | //Select row prev until indent is less than current
183 | let row = cursor.parent()
184 | let tabs = Math.max(0, parseInt(row.attr('tabs')) - 1) //Max with 0 so tabs can't go negative
185 | let prevs = row.prevAll(`[tabs="${tabs}"]`)
186 | if (prevs.length) {
187 | newCur = prevs.first().children().first()
188 | } else {
189 | newCur = cursor
190 | }
191 | }
192 |
193 | cursors.removeClass('cur')
194 | newCur.addClass('cur')
195 | select(newCur)
196 | }
197 |
198 |
199 | function selSimilar(e, opts) {
200 | if (e && e.preventDefault) {e.preventDefault()}
201 |
202 | opts = opts || ''
203 | let cursor = $('.cur').last()
204 | let text = cursor.attr('text')
205 | let similars = $(`[text="${text}"]`)
206 | let newCur
207 | if (opts.includes(':all')) {
208 | newCur = similars
209 | } else {
210 | //Find cursor among similars and get next
211 | let index = similars.index(cursor) + 1
212 | newCur = similars.eq(index)
213 | if (!newCur.length) {
214 | //If there's no next, start searching from the beginning of the document
215 | newCur = $(`[text="${text}"]:not(.cur)`).first()
216 | }
217 | }
218 | newCur.addClass('cur')
219 | select(newCur)
220 | }
221 |
222 |
223 | function selAll(e) {
224 | //Should this select the whole row first and only after that the whole doc?
225 | $('.cur').removeClass('cur')
226 | $('doc row:last :last-child').addClass('cur')
227 | select($('tag, prop, val, txt'))
228 | }
229 |
230 |
--------------------------------------------------------------------------------
/js/editing.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | //All these functions work for any combination of selections that have rows or props. Editing is not limited to any kind of props/rows modes, but should be able handle anything. If we want to limit editing to rows at a time or props at a time, selections should be limited to props or rows at a time.
4 |
5 |
6 | let editmodeStartValue = null //The value of a tag/prop/val/txt needs to be shared so i can check if the value has changed between entering edit mode and committing the edit.
7 |
8 |
9 | function startEdit (e, opts) {
10 | if (e && e.preventDefault) {e.preventDefault()}
11 |
12 | //startEdit could be called from many contexts, so it doen't itself have history.update, history.update needs to be called before invoking startEdit if needed
13 |
14 | //TODO: startEdit should probably collapse selection to cursors
15 |
16 | scope = 'editing'
17 | opts = opts || ''
18 |
19 | let cur = $('.cur')
20 | let target = cur.first()
21 | let clones = cur.not(target)
22 | editmodeStartValue = target.text()
23 | select(cur)
24 | $('.hilite').removeClass('hilite') //Remove row hilite so editing a tag & txt looks more explicit
25 | clones.addClass('clone')
26 | target.attr('contenteditable', 'true').focus()
27 |
28 | if (opts.includes(':selectEnd')) {
29 | target.selectEnd()
30 | }
31 | else {
32 | target.selectText()
33 | }
34 | }
35 |
36 | function commitEdit () {
37 |
38 | let target = $('[contenteditable="true"]')
39 | let text = target.text()
40 | let clones = $('.clone')
41 | clones.removeClass('clone')
42 | target.attr('contenteditable', 'false')
43 | select($(target, clones)) //Re-select what was being edited to restore proper selection state, because row hilites have been removed during editing.
44 |
45 | scope = ''
46 |
47 | if (text === '') {
48 | del(':emptydelete:backward')
49 | }
50 | else if (editmodeStartValue !== text) {
51 | history.add()
52 | }
53 |
54 | editmodeStartValue = null
55 | }
56 |
57 |
58 | function input (node) {
59 | //input() takes in a node instead of event is that it can be called from anywhere, not just from input event. So you can trigger autofill programmatically when creating tags, props & vals.
60 | //This function could parse input and create stuff based on that, but that logic is probably better to put in the paste handler and keyboard shortcuts.
61 |
62 | //Could use discard.js here to throw away any invalid characters for tags, props & vals as the user is typing them, so you could never add anything illegal in the tag. Cleanup could also happen on commitEdit?
63 |
64 | let cur = $('.cur')
65 | let clones = $('.clone')
66 | let tagName = node.tagName
67 | let text = node.innerText
68 | let lastChar = text.slice(-1)
69 |
70 | //Try autofill
71 | autofill.fill(node)
72 |
73 | //If multiple items are being edited, match their text
74 | text = node.innerText
75 | clones.text(text)
76 | //Update text attributes to match text content for easier selection via dom queries
77 | cur.attr('text', text)
78 | }
79 |
80 |
81 | function createRow (e, opts, str) {
82 | if (e && e.preventDefault) {e.preventDefault()}
83 |
84 | history.update()
85 |
86 | let tag = ''
87 | let txt = ''
88 | opts = opts || ''
89 | str = str || ''
90 |
91 | if (opts.includes(':txt')) {txt = str || (e && e.key) || ' '}
92 | else if (opts.includes(':tag')) {tag = str || (e && e.key)}
93 | else {tag = 'div'}
94 |
95 | if (txt || tag) {
96 | let cursors = $('.cur')
97 | let sel = $('.sel')
98 | let selrows = cursors.parent()
99 | let template
100 |
101 | if (txt) {template = $(`${txt}`)}
102 | else if (tag) {template = $(`${tag}`)}
103 |
104 | if (selrows.length) {
105 | selrows.after(template)
106 | } else { //If there's no selection, add stuff at the end of doc
107 | $('doc').append(template)
108 | }
109 |
110 | let newRows = $('.new').removeClass('new')
111 | newRows.each(function(index, row) {
112 | let newRow = $(row)
113 | let prevRow = newRow.prev()
114 | let nextTabs = parseInt(newRow.next().attr('tabs')) || 0
115 | let prevTabs = parseInt(prevRow.attr('tabs')) || 0
116 | if (txt) {
117 | if (prevRow.attr('type') === 'tag') {
118 | newRow.attr('tabs', prevTabs + 1)
119 | } else if (prevRow.attr('type') === 'txt') { //Text can't be a child of text so make a new textrow a sibling of previous textrow
120 | newRow.attr('tabs', prevTabs)
121 | } else {
122 | newRow.attr('tabs', 0)
123 | }
124 | } else if (tag) {
125 | newRow.attr('tabs', Math.max(nextTabs, prevTabs))
126 | }
127 | })
128 |
129 | let newCurs = newRows.children()
130 | cursors.removeClass('cur')
131 | newCurs.addClass('cur')
132 | select(newCurs)
133 |
134 | //If the user starts creatig a tag or a text with a letter, then don't select the whole thing so the user can just continue typing
135 | if ((tag && tag.length === 1) || (txt && txt !== ' ')) {
136 | startEdit(e, ':selectEnd')
137 | } else {
138 | //Else select the whole thing, so the user can start typing and replace whatever intial vaue we guessed into the created item
139 | //If the tag value was prefilled to be something, don't autofill right away, because it messes up the selection
140 | startEdit(e)
141 | autofill.prevent()
142 | }
143 |
144 | input(document.querySelector('[contenteditable="true"]'))
145 | }
146 | }
147 |
148 |
149 | function createProp (e, type, str) {
150 | if (e && e.preventDefault) {e.preventDefault()}
151 |
152 | history.update()
153 |
154 | let sel = $('.sel')
155 | let cursors = $('.cur')
156 | let action = false
157 |
158 | if (cursors.length === 0) {return} //Can't add props to nonexistent selections
159 |
160 | //Treat each cursor individually
161 | cursors.each((i, el)=>{
162 | if (el.tagName === 'TXT') {
163 | //Text rows can't have props
164 | return
165 | }
166 |
167 | type = type || ''
168 | let cur = $(el)
169 | action = true
170 |
171 | //Without options, try to automatically add the right thing
172 | if (type.includes(':val') || (type === '' && el.tagName === 'PROP')) {
173 | str = str || 'value'
174 | cur.after(`${str}`)
175 | }
176 | else if (type.includes(':prop') || (type === '' && ['TAG','VAL'].includes(el.tagName))) {
177 | str = str || 'attr'
178 | cur.after(`${str}`)
179 | }
180 |
181 | //id & class check if the element already has an id/class and act according to that. If there's an id, just edit the id val, if there's a class, add a class after that
182 | //TODO: add id & add class should act on hilited rows, not cursors, so you never get a double class added to a row if there's two or more cursors on a row
183 | if (type.includes(':id')) {
184 | let idProp = cur.parent().find('prop[text="id"] + val')
185 | //TODO: first check if there's an id+val combo and add new to val if there is
186 | //then check if there's a lone id prop without val, if there is, add a new val for id
187 | //else add new id+val combo after tag
188 | if (idProp.length) {
189 | idProp.addClass('new')
190 | } else {
191 | cur.after(`id${str}`)
192 | }
193 | }
194 | if (type.includes(':class')) {
195 | //TODO: first check if there's a class + val combo
196 | //then check if there's a lone class
197 | //else add class after tag or id+val
198 | let prop = `class`
199 | let val = `${str}`
200 | let classProp = cur.parent().find('prop[text="class"]')
201 | if (classProp.length) {
202 | classProp.nextUntil('prop').last().after(val)
203 | } else {
204 | cur.after(prop + val)
205 | }
206 | }
207 | })
208 |
209 | if (action) {
210 | let newCurs = $('.new').removeClass('new')
211 | cursors.removeClass('cur')
212 | newCurs.addClass('cur')
213 | select(newCurs)
214 | startEdit(e)
215 | }
216 | }
217 |
218 |
219 | function del (opts) {
220 |
221 | opts = opts || ':backward'
222 | let sel = $('.sel')
223 | let cursors = $('.cur')
224 |
225 | if (opts.includes(':emptydelete') === false) {
226 | history.update()
227 | }
228 |
229 |
230 | let newCurs = $()
231 | sel.each(function(i, el) {
232 | let $el = $(el)
233 | if (el.tagName === 'TAG' || el.tagName === 'TXT') {
234 | if (opts.includes(':backward')) {newCurs = newCurs.add($el.parent().prev().children().first())}
235 | if (opts.includes(':forward')) {newCurs = newCurs.add($el.parent().next().children().first())}
236 | } else {
237 | let lastchild = $el.is(':last-child')
238 | if (opts.includes(':backward') || lastchild) {newCurs = newCurs.add($el.prev())}
239 | //Forward delete moves only on the selected row, selection does not jump to next row even if the :last prop is forward deleted, that's why there's the last-child check
240 | else if (opts.includes(':forward')) {newCurs = newCurs.add($el.next())}
241 | }
242 | if ($el.prev().length) {
243 | newCurs = newCurs.add($el)
244 | }
245 | })
246 |
247 | //Find tags & txt in selection and delete their parents. Those are proxies for the whole row, so rows should get deleted with them.
248 | sel.filter('tag, txt').parent().remove()
249 |
250 | //If you delete a prop, its attr will get deleted too. Not strictly necessary because you can have lonely values like