From f2cc16341d21fdc07cbc956936b89dbe912d0ede Mon Sep 17 00:00:00 2001 From: Peter Stuifzand Date: Sun, 31 May 2020 22:08:36 +0200 Subject: [PATCH] Add autocomplete of page links --- backref.go | 14 +++- editor/package-lock.json | 94 +++++++++++++++++++++-- editor/package.json | 4 +- editor/src/caret-position.js | 145 +++++++++++++++++++++++++++++++++++ editor/src/fuse.js | 20 +++++ editor/src/index.js | 106 +++++++++++++++++++++++++ main.go | 50 +++++++++--- templates/edit.html | 2 +- templates/layout.html | 33 +++++++- templates/view.html | 2 +- 10 files changed, 445 insertions(+), 25 deletions(-) create mode 100644 editor/src/caret-position.js create mode 100644 editor/src/fuse.js diff --git a/backref.go b/backref.go index 3645e20..ce8b06a 100644 --- a/backref.go +++ b/backref.go @@ -91,10 +91,18 @@ func loadBackrefs(fp *FilePages, p string) (map[string][]Backref, error) { links := renderLinks(strings.TrimLeft(ref.Link.Line, " *")) pageText := renderMarkdown2(links) + removeBrackets := func(r rune) rune { + if r == '[' || r == ']' || r == '*' { + return -1 + } + return r + } + result[ref.Name] = append(result[ref.Name], Backref{ - Name: ref.Name, - Title: title, - Line: template.HTML(pageText), + Name: ref.Name, + Title: title, + LineHTML: template.HTML(pageText), + Line: strings.Map(removeBrackets, ref.Link.Line), }) } diff --git a/editor/package-lock.json b/editor/package-lock.json index 6e60b87..b3aa1db 100644 --- a/editor/package-lock.json +++ b/editor/package-lock.json @@ -1093,6 +1093,14 @@ "del": "^4.1.1" } }, + "cli": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/cli/-/cli-0.4.3.tgz", + "integrity": "sha1-5oGcjV+qlX9k+Y9mqFBiaMHR8X0=", + "requires": { + "glob": ">= 3.1.4" + } + }, "cliui": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", @@ -1141,6 +1149,11 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" }, + "colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==" + }, "combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -2935,6 +2948,24 @@ "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", "dev": true }, + "fuse": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/fuse/-/fuse-0.4.0.tgz", + "integrity": "sha1-LDjq+IirsKm6eWDP4zOdHz9T9uY=", + "requires": { + "colors": ">=0.6.x", + "fuse.js": "^6.0.0", + "jshint": "0.9.x", + "optimist": ">=0.3.5", + "uglify-js": ">=2.2.x", + "underscore": ">=1.4.x" + } + }, + "fuse.js": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-6.0.0.tgz", + "integrity": "sha512-e5Ap6mhF/WQ9bKqsMFTTR5/DS9qbYab4VXHtMdxCanH+VZkdUV2LqcgMO31etSQv53NXsguQF1bdqkrrPAM2HQ==" + }, "gauge": { "version": "2.7.4", "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", @@ -3857,6 +3888,30 @@ "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=" }, + "jshint": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/jshint/-/jshint-0.9.1.tgz", + "integrity": "sha1-/zLsfwn4QAH3SY7q/WPJ5Puy3A4=", + "requires": { + "cli": "0.4.3", + "minimatch": "0.0.x" + }, + "dependencies": { + "lru-cache": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-1.0.6.tgz", + "integrity": "sha1-qlD5cEdCKsclQ72hd6nJ0BjZhFI=" + }, + "minimatch": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.0.5.tgz", + "integrity": "sha1-lrtJC707poNrv6wRGt91MBsVhN4=", + "requires": { + "lru-cache": "~1.0.2" + } + } + } + }, "json-parse-better-errors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", @@ -4744,6 +4799,22 @@ "is-wsl": "^1.1.0" } }, + "optimist": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", + "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=", + "requires": { + "minimist": "~0.0.1", + "wordwrap": "~0.0.2" + }, + "dependencies": { + "minimist": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", + "integrity": "sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=" + } + } + }, "original": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/original/-/original-1.0.2.tgz", @@ -6892,7 +6963,6 @@ "version": "3.4.10", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.4.10.tgz", "integrity": "sha512-Y2VsbPVs0FIshJztycsO2SfPk7/KAF/T72qzv9u5EpQ4kB2hQoHlhNQTsNyy6ul7lQtqJN/AoWeS23OzEiEFxw==", - "dev": true, "requires": { "commander": "~2.19.0", "source-map": "~0.6.1" @@ -6901,17 +6971,20 @@ "commander": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/commander/-/commander-2.19.0.tgz", - "integrity": "sha512-6tvAOO+D6OENvRAh524Dh9jcfKTYDQAqvqezbCW82xj5X0pSrcpxtvRKHLG0yBY6SD7PSDrJaj+0AiOcKVd1Xg==", - "dev": true + "integrity": "sha512-6tvAOO+D6OENvRAh524Dh9jcfKTYDQAqvqezbCW82xj5X0pSrcpxtvRKHLG0yBY6SD7PSDrJaj+0AiOcKVd1Xg==" }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" } } }, + "underscore": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.10.2.tgz", + "integrity": "sha512-N4P+Q/BuyuEKFJ43B9gYuOj4TQUHXX+j2FqguVOpjkssLUUrnJofCcBccJSCoeturDoZU6GorDTHSvUDlSQbTg==" + }, "union-value": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", @@ -7538,15 +7611,20 @@ } }, "wiki-list-editor": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/wiki-list-editor/-/wiki-list-editor-0.6.4.tgz", - "integrity": "sha512-UkCrS62HR6/Slk2jggxQZryoe3YEbBPrceJ76sl6a4YPni/xWCajLGl/qLsUemQMwyoVI7ApAmLE9GvJeLLPyQ==", + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/wiki-list-editor/-/wiki-list-editor-0.6.5.tgz", + "integrity": "sha512-1/XQ7JSJoet/TCq5RiF0JAHFZYveI3fEW8drc6gPnAfwz4e2t7vHBZV5sDhFO+z4fD+uISfsBveW4oJzVNteNA==", "requires": { "dragula": "^3.7.2", "jquery": "^3.5.1", "lodash": "^4.17.15" } }, + "wordwrap": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", + "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=" + }, "worker-farm": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/worker-farm/-/worker-farm-1.7.0.tgz", diff --git a/editor/package.json b/editor/package.json index acd707a..fa9e9f0 100644 --- a/editor/package.json +++ b/editor/package.json @@ -12,13 +12,15 @@ "bulma": "^0.7.5", "css-loader": "^3.2.0", "file-loader": "^6.0.0", + "fuse": "^0.4.0", + "fuse.js": "^6.0.0", "jquery-contextmenu": "^2.9.2", "lunr": "^2.3.8", "mustache": "^4.0.1", "node-sass": "^4.14.1", "sass-loader": "^7.3.1", "style-loader": "^1.0.0", - "wiki-list-editor": "^0.6.4" + "wiki-list-editor": "^0.6.5" }, "scripts": { "test": "echo \"Error: no test specified\" && exit 1", diff --git a/editor/src/caret-position.js b/editor/src/caret-position.js new file mode 100644 index 0000000..e39cfc3 --- /dev/null +++ b/editor/src/caret-position.js @@ -0,0 +1,145 @@ +// We'll copy the properties below into the mirror div. +// Note that some browsers, such as Firefox, do not concatenate properties +// into their shorthand (e.g. padding-top, padding-bottom etc. -> padding), +// so we have to list every single property explicitly. +var properties = [ + 'direction', // RTL support + 'boxSizing', + 'width', // on Chrome and IE, exclude the scrollbar, so the mirror div wraps exactly as the textarea does + 'height', + 'overflowX', + 'overflowY', // copy the scrollbar for IE + + 'borderTopWidth', + 'borderRightWidth', + 'borderBottomWidth', + 'borderLeftWidth', + 'borderStyle', + + 'paddingTop', + 'paddingRight', + 'paddingBottom', + 'paddingLeft', + + // https://developer.mozilla.org/en-US/docs/Web/CSS/font + 'fontStyle', + 'fontVariant', + 'fontWeight', + 'fontStretch', + 'fontSize', + 'fontSizeAdjust', + 'lineHeight', + 'fontFamily', + + 'textAlign', + 'textTransform', + 'textIndent', + 'textDecoration', // might not make a difference, but better be safe + + 'letterSpacing', + 'wordSpacing', + + 'tabSize', + 'MozTabSize' + +]; + +var isBrowser = (typeof window !== 'undefined'); +var isFirefox = (isBrowser && window.mozInnerScreenX != null); + +function getCaretCoordinates(element, position, options) { + if (!isBrowser) { + throw new Error('textarea-caret-position#getCaretCoordinates should only be called in a browser'); + } + + var debug = options && options.debug || false; + if (debug) { + var el = document.querySelector('#input-textarea-caret-position-mirror-div'); + if (el) el.parentNode.removeChild(el); + } + + // The mirror div will replicate the textarea's style + var div = document.createElement('div'); + div.id = 'input-textarea-caret-position-mirror-div'; + document.body.appendChild(div); + + var style = div.style; + var computed = window.getComputedStyle ? window.getComputedStyle(element) : element.currentStyle; // currentStyle for IE < 9 + var isInput = element.nodeName === 'INPUT'; + + // Default textarea styles + style.whiteSpace = 'pre-wrap'; + if (!isInput) + style.wordWrap = 'break-word'; // only for textarea-s + + // Position off-screen + style.position = 'absolute'; // required to return coordinates properly + if (!debug) + style.visibility = 'hidden'; // not 'display: none' because we want rendering + + // Transfer the element's properties to the div + properties.forEach(function (prop) { + if (isInput && prop === 'lineHeight') { + // Special case for s because text is rendered centered and line height may be != height + if (computed.boxSizing === "border-box") { + var height = parseInt(computed.height); + var outerHeight = + parseInt(computed.paddingTop) + + parseInt(computed.paddingBottom) + + parseInt(computed.borderTopWidth) + + parseInt(computed.borderBottomWidth); + var targetHeight = outerHeight + parseInt(computed.lineHeight); + if (height > targetHeight) { + style.lineHeight = height - outerHeight + "px"; + } else if (height === targetHeight) { + style.lineHeight = computed.lineHeight; + } else { + style.lineHeight = 0; + } + } else { + style.lineHeight = computed.height; + } + } else { + style[prop] = computed[prop]; + } + }); + + if (isFirefox) { + // Firefox lies about the overflow property for textareas: https://bugzilla.mozilla.org/show_bug.cgi?id=984275 + if (element.scrollHeight > parseInt(computed.height)) + style.overflowY = 'scroll'; + } else { + style.overflow = 'hidden'; // for Chrome to not render a scrollbar; IE keeps overflowY = 'scroll' + } + + div.textContent = element.value.substring(0, position); + // The second special handling for input type="text" vs textarea: + // spaces need to be replaced with non-breaking spaces - http://stackoverflow.com/a/13402035/1269037 + if (isInput) + div.textContent = div.textContent.replace(/\s/g, '\u00a0'); + + var span = document.createElement('span'); + // Wrapping must be replicated *exactly*, including when a long word gets + // onto the next line, with whitespace at the end of the line before (#7). + // The *only* reliable way to do that is to copy the *entire* rest of the + // textarea's content into the created at the caret position. + // For inputs, just '.' would be enough, but no need to bother. + span.textContent = element.value.substring(position) || '.'; // || because a completely empty faux span doesn't render at all + div.appendChild(span); + + var coordinates = { + top: span.offsetTop + parseInt(computed['borderTopWidth']), + left: span.offsetLeft + parseInt(computed['borderLeftWidth']), + height: parseInt(computed['lineHeight']) + }; + + if (debug) { + span.style.backgroundColor = '#aaa'; + } else { + document.body.removeChild(div); + } + + return coordinates; +} + +export default getCaretCoordinates; diff --git a/editor/src/fuse.js b/editor/src/fuse.js new file mode 100644 index 0000000..c705b38 --- /dev/null +++ b/editor/src/fuse.js @@ -0,0 +1,20 @@ +import Fuse from 'fuse.js' +import $ from 'jquery' + +function createTitleSearch() { + return new Promise(function (resolve, reject) { + $.get('/links.json', function (documents) { + const options = { + keys: ['title'], + } + let fuse = new Fuse(documents, options) + + resolve({ + documents, + search: fuse, + }) + }) + }) +} + +export default createTitleSearch diff --git a/editor/src/index.js b/editor/src/index.js index ac2979e..d14354f 100644 --- a/editor/src/index.js +++ b/editor/src/index.js @@ -3,8 +3,10 @@ import axios from 'axios'; import qs from 'querystring' import $ from 'jquery'; import search from './search'; +import createPageSearch from './fuse'; import Mustache from 'mustache'; import 'jquery-contextmenu'; +import getCaretCoordinates from './caret-position' import './styles.scss'; import '../node_modules/jquery-contextmenu/dist/jquery.contextMenu.css'; @@ -75,6 +77,110 @@ if (holder) { ).save() }) + $(document).on('keydown', '#link-complete', function (event) { + if (!$('#link-complete:visible')) { + return true; + } + const $popup = $('#link-complete') + if (event.key === 'Escape') { + $popup.fadeOut() + return false; + } + + if (event.key === 'Enter') { + const linkName = $popup.find('li.selected').text() + $popup.trigger('popup:selected', [linkName]) + $popup.fadeOut() + return false + } + + if (event.key === 'ArrowUp') { + const selected = $popup.find('li.selected') + const prev = selected.prev('li') + if (prev.length) { + prev.addClass('selected') + selected.removeClass('selected') + } + return false; + } + + if (event.key === 'ArrowDown') { + const selected = $popup.find('li.selected') + const next = selected.next('li'); + if (next.length) { + next.addClass('selected') + selected.removeClass('selected') + } + return false + } + + return true + }) + + createPageSearch().then(function ({search}) { + editor.on('start-editing', function (input) { + const $lc = $('#link-complete') + + $lc.on('popup:selected', function (event, linkName) { + let value = input.value + let end = input.selectionEnd + let start = value.lastIndexOf("[[", end) + end += 2 + let startAndLink = value.substring(0, start) + "[[" + linkName + "]]" + input.value = startAndLink + value.substring(end) + input.selectionStart = startAndLink.length + input.selectionEnd = startAndLink.length + input.focus() + return true + }) + + $(input).on('keydown', function (event) { + const isVisible = $('#link-complete:visible').length > 0; + + const $lc = $('#link-complete') + if (event.key === 'Escape' && isVisible) { + $lc.fadeOut() + return true + } + + if (event.key === 'ArrowDown' && isVisible) { + $lc.focus() + $lc.find('li:first-child').addClass('selected') + return false + } + return true + }) + + $(input).on('keyup', function () { + let value = input.value + let end = input.selectionEnd + let start = value.lastIndexOf("[[", end) + let linkEnd = value.lastIndexOf("]]", end - 1) + if (start < linkEnd) { + return + } + let query = value.substring(start, end); + let results = search.search(query) + + let pos = getCaretCoordinates(input, value.selectionEnd, {}) + let off = $(input).offset() + pos.top += off.top + pos.height + pos.left += off.left + + const $lc = $('#link-complete') + $lc.offset(pos) + + var template = document.getElementById('link-template').innerHTML; + var rendered = Mustache.render(template, {results: results}, {}, ['[[', ']]']); + $lc.html(rendered).fadeIn() + }) + }) + + editor.on('stop-editing', function (input) { + $('#link-complete').off() + }) + }) + $.contextMenu({ selector: '.marker', items: { diff --git a/main.go b/main.go index c6ac062..86b2497 100644 --- a/main.go +++ b/main.go @@ -30,9 +30,10 @@ var ( ) type Backref struct { - Name string - Title string - Line template.HTML + Name string + Title string + LineHTML template.HTML + Line string } // Page @@ -618,6 +619,27 @@ func main() { mp = NewFilePages("data") http.Handle("/auth/", &authHandler{}) + http.HandleFunc("/links.json", func(w http.ResponseWriter, r *http.Request) { + type Document struct { + Title string `json:"title"` + } + + var results []Document + pages, err := mp.(*FilePages).AllPages() + if err != nil { + http.Error(w, err.Error(), 500) + return + } + for _, page := range pages { + results = append(results, Document{page.Title}) + } + w.Header().Set("Content-Type", "application/json") + err = json.NewEncoder(w).Encode(&results) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + }) http.HandleFunc("/documents.json", func(w http.ResponseWriter, r *http.Request) { type Document struct { Title string `json:"title"` @@ -632,26 +654,36 @@ func main() { return } for _, page := range pages { - content := page.Content + content := strings.Builder{} var listItems []struct { Indented int Text string } - err = json.NewDecoder(strings.NewReader(content)).Decode(&listItems) + err = json.NewDecoder(strings.NewReader(page.Content)).Decode(&listItems) if err == nil { - pageText := "" for _, item := range listItems { - pageText += strings.Repeat(" ", item.Indented) + "* " + item.Text + "\n" + content.WriteString(item.Text) + content.WriteByte(' ') } + } else { + content.WriteString(page.Content) + content.WriteByte(' ') + } - content = pageText + for page, refs := range page.Refs { + content.WriteString(page) + content.WriteByte(' ') + for _, ref := range refs { + content.WriteString(ref.Line) + content.WriteByte(' ') + } } results = append(results, Document{ Title: page.Title, - Body: content, + Body: content.String(), URL: page.Name, }) } diff --git a/templates/edit.html b/templates/edit.html index 388bd8b..3592d3b 100644 --- a/templates/edit.html +++ b/templates/edit.html @@ -17,7 +17,7 @@
  • {{ (index $refs 0).Title }}
      {{ range $ref := $refs }} -
    • {{ $ref.Line }}
    • +
    • {{ $ref.LineHTML }}
    • {{ end }}
  • diff --git a/templates/layout.html b/templates/layout.html index f2bc2a9..92ba687 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -25,6 +25,24 @@ #autocomplete li > a { white-space: nowrap; } + #link-complete { + z-index: 1; + width: 217px; + overflow-x: hidden; + overflow-y: auto; + height: 300px; + position: absolute; + background: white; + border: 1px solid #ccc; + outline: none; + } + + #link-complete li { + padding: 4px 16px; + } + #link-complete li.selected { + background: lightblue; + } .monospace { font-family: "Fira Code Retina", monospace; @@ -142,6 +160,9 @@ border-bottom-color: #ccc; } + .hide { + display: none; + } .selected { background: lightblue; @@ -249,12 +270,13 @@ — created by Peter Stuifzand - +
    + {{ block "footer_scripts" . }} {{ end }} -