From c4d23b6e8ad6335be5b251b5dc248c0a8a5a43bd Mon Sep 17 00:00:00 2001 From: Derek Ziemba Date: Thu, 9 May 2024 11:46:08 -0500 Subject: [PATCH 1/6] add package.json --- .vscode/launch.json | 16 +++++ package.json | 39 ++++++++++++ package.ps1 | 141 +++++++++++++++++++++++++++++++++++++++----- 3 files changed, 182 insertions(+), 14 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 package.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..9ba4583 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "build", + "type": "PowerShell", + "request": "launch", + "script": "${workspaceRoot}/package.ps1", + "cwd": "${workspaceRoot}", + "args": ["-Local"] + }, + ], + "compounds": [ + + ] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..984ea23 --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "@dwachss/bililite-range", + "version": "4.0.1", + "description": "bililiteRange is a javascript library that abstracts text selection and replacement.", + "keywords": [ + "sendkeys", + "text selection", + "text replacement" + ], + "author": { + "name": "Danny Wachsstock", + "email": "d.wachss@prodigy.net", + "url": "https://bililite.com/blog" + }, + "repository": { + "type": "git", + "url": "https://github.com/dwachss/bililiteRange.git" + }, + "license": "MIT", + "homepage": "https://github.com/dwachss/bililiteRange", + "bugs": { + "url": "https://github.com/dwachss/bililiteRange/issues" + }, + "type": "module", + "main": "dist/bililiteRange.js", + "directories": { + "doc": "docs", + "test": "test" + }, + "scripts": { + "build": "pwsh ./package.ps1 -Local" + }, + "dependencies": { + + }, + "devDependencies": { + + } +} diff --git a/package.ps1 b/package.ps1 index c3c1827..cdfc596 100644 --- a/package.ps1 +++ b/package.ps1 @@ -1,4 +1,7 @@ -# concatenates files +Param( + [Parameter()][switch]$Local +) +# concatenates files $targets = @{ "editor.js" = @( @@ -21,31 +24,141 @@ $targets = @{ $shaSize = 7 +function Get-Repo { + param([Parameter(Mandatory=$true)][string]$Source) + (Split-Path $source) -replace '\\', '/' +} + function Get-Sha { param($repo) if ($repo) { - [char[]]((Invoke-WebRequest https://api.github.com/repos/$repo/commits/master -H @{Accept = 'application/vnd.github.sha'}).Content[0..$shaSize]) -join '' + $url = "https://api.github.com/repos/$repo/commits/master" + $headers = @{Accept = 'application/vnd.github.sha'} + $req = (Invoke-WebRequest $url -Headers $headers) + $content = $req.Content[0..$shaSize] + [char[]]($content) -join '' }else{ git rev-parse --short=$shaSize HEAD } } function Get-Source-Content { - param($repo, $file) - if ($repo){ - (Invoke-WebRequest https://raw.githubusercontent.com/$repo/master/$file).Content - }else{ - Get-Content $file - } + param( + [Parameter(Mandatory=$true)][string]$File, + [Parameter(Mandatory=$false)][string[]]$Repo + ) + $existsLocally = [System.IO.File]::Exists($File) + $remote = "https://raw.githubusercontent.com/$repo/master/$file" + + $result = $null + if ($Repo) { + if ($Local.IsPresent -and $existsLocally) { + $result = Get-Content $file + if ([string]::IsNullOrWhiteSpace($result)) { + Write-Warning "Failed to get content from local file $File, using remote $repo`n $remote" + $req = Invoke-WebRequest $remote + if ($req) { + $result = $req.Content + if (!$result) { + Write-Error "No Content returned from remote $repo`n $remote" + } + } + } + } else { + $req = Invoke-WebRequest $remote + if ($req) { + $result = $req.Content + } + if ([string]::IsNullOrWhiteSpace($result)) { + Write-Warning "Failed to get content from $repo`n $remote`n, using local file $File" + $result = Get-Content $file + if (!$result) { + Write-Error "Failed to get content from local file $File,`n and the remote!" + } + } + } + } else { + $result = Get-Content $file + if (!$result) { + Write-Error "Failed to get content from local file $File, using remote $repo to fallback to was specified" + } + } + ($result | % { [string]$_ }) -as [string[]] +} + +$pkgJson = Get-Source-Content "package.json" | ConvertFrom-Json + + +function Wrap-Content { + param( + [Parameter(Mandatory=$true)][string]$Source, + [Parameter(Mandatory=$true)][Array]$Content, + [Parameter(Mandatory=$false)][hashtable]$Info + ) + if (!$Info) { $Info = @{} } + $hash = [ordered]@{ } + $hash.source = if ([string]::IsNullOrWhiteSpace($Info.source)) { $Source } else { $Info.source } + $hash.file = if ([string]::IsNullOrWhiteSpace($Info.file)) { Split-Path $Source -leaf } else { $Info.file } + $hash.repo = if ([string]::IsNullOrWhiteSpace($Info.repo)) { Get-Repo $Source } else { $Info.repo } + $hash.commit = if ([string]::IsNullOrWhiteSpace($Info.commit)) { Get-Sha $hash.repo } else { $Info.commit } + $hash.version = if ([string]::IsNullOrWhiteSpace($Info.version)) { $pkgJson.version } else { $Info.version } + $hash.date = if ([string]::IsNullOrWhiteSpace($Info.date)) { Get-Date -Format 'yyyy-MM-dd' } else { $Info.date } + $Info.GetEnumerator() | % { + if (!$hash.Contains($_.Key)) { + $hash[$_.Key] = $_.Value + } + } + + $sha = Get-Sha $repo + $file = Split-Path $Source -leaf + $hashlines = $hash.GetEnumerator() | % { + $k = $_.Key + $v = $_.Value + $name = $k.PadLeft(10) + " * $($name): $v" + } + $lines = @( + "", + "", + "/$('*'*49)", + $hashlines, + " $('*'*48)/", + "", + $Content, + "" + ) | % { $_ } + $lines } + + foreach ($target in $targets.Keys){ - "// $target $( Get-Date -Format 'yyyy-MM-dd')" > dist/$target + $path = "dist/$target" + $targetLines = [System.Collections.Generic.List[string]]::new() foreach ($source in $targets[$target]){ - $repo = (Split-Path $source) -replace '\\', '/' - $file = Split-Path $source -leaf - "" >> dist/$target - "// $source commit $(Get-Sha $repo)" >> dist/$target - Get-Source-Content $repo $file >> dist/$target + $pref = $ErrorActionPreference + try { + $ErrorActionPreference = 'Break' + $file = Split-Path $Source -leaf + $repo = Get-Repo $source + $content = Get-Source-Content $file $repo + $lines = Wrap-Content -Source $source -Content $content -Info @{ source = $source; file = $file; repo = $repo } + foreach($ln in $lines){ + $targetLines.Add($ln) + } + } catch { + $targetLines = $null + $ErrorActionPreference = $pref + Write-Error $_ + } finally { + $ErrorActionPreference = $pref + } } + if ($targetLines -and $targetLines.Count -gt 0) { + if ([System.IO.File]::Exists($path)) { + Remove-Item $path + } + $content = ($targetLines -join "`n").Trim(); + $content >> $path + } } From a0a7794cef276a0ab2ecdc4d24cb781394fe214f Mon Sep 17 00:00:00 2001 From: Derek Ziemba Date: Thu, 9 May 2024 11:50:58 -0500 Subject: [PATCH 2/6] feat(nodemodule): hack in ability to use as a node module --- .vscode/settings.json | 5 + bililiteRange.evim.js | 15 +- bililiteRange.ex.js | 62 ++- bililiteRange.find.js | 8 +- bililiteRange.js | 1050 ++++++++++++++++++++++------------------ bililiteRange.lines.js | 6 +- bililiteRange.undo.js | 5 +- module-support.js | 67 +++ 8 files changed, 709 insertions(+), 509 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 module-support.js diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..cc7e8a9 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "[javascript]": { + + } +} diff --git a/bililiteRange.evim.js b/bililiteRange.evim.js index afa975a..c75700d 100644 --- a/bililiteRange.evim.js +++ b/bililiteRange.evim.js @@ -1,6 +1,5 @@ -'use strict'; +const { bililiteRange } = require('./bililiteRange.js'); -(function(){ const editorKey = Symbol(); // marker @@ -41,7 +40,7 @@ bililiteRange.prototype.evim = function (toolbarContainer, statusbar){ } } }); - + // A variation on VIM keys, in visual mode, as evim (every command starts with ctrl-o then goes back to insert mode) this.listen('keydown', keymap(/ctrl-o (?:ctrl-)?[:;]/, evt => { Promise.prompt(':', statusbar). @@ -74,7 +73,7 @@ bililiteRange.prototype.evim = function (toolbarContainer, statusbar){ i: ['whole', false, undefined], t: ['to', false, 'endbounds'], }; - + const evim1 = RegExp (`ctrl-o ([${Object.keys(vimverbs).join('')}]) ([${Object.keys(vimobjects).join('')}])`); const handler1 = keymap(evim1, evt => { const match = evim1.exec(evt.keymapSequence); @@ -104,9 +103,9 @@ bililiteRange.prototype.evim = function (toolbarContainer, statusbar){ rng.select().scrollIntoView(); }); this.listen('keydown', handler3); - - - + + + this.ex('%%source .exrc'); this.initUndo(true); // attach the ctrl-z, ctrl-y handlers }; @@ -152,4 +151,4 @@ function parseToolbarCommand(string){ return ret; } -})(); \ No newline at end of file +module.exports = bililiteRange; diff --git a/bililiteRange.ex.js b/bililiteRange.ex.js index bad8ecb..8cbf7d8 100644 --- a/bililiteRange.ex.js +++ b/bililiteRange.ex.js @@ -1,6 +1,4 @@ -'use strict'; - -(function(undefined){ +const { bililiteRange } = require('./bililiteRange.js'); /*********************** the actual ex plugin *********************************/ bililiteRange.ex = {}; // namespace for exporting utility functions @@ -45,7 +43,7 @@ bililiteRange.prototype.ex = function (commandstring = '', defaultaddress = '.') data.directory ??= this.window.location.origin; data.file ??= this.window.location.pathname; // if this is set to the empty string, then don't save anything. - + addListener (this, 'visibilitychange', evt => { if (document.visibilityState == 'hidden') preserve(this); }, document); @@ -83,7 +81,7 @@ bililiteRange.prototype.ex = function (commandstring = '', defaultaddress = '.') let parsed = parseCommand(command, defaultaddress); interpretAddresses(this, parsed.addresses, data); parsed.command.call(this, parsed.parameter, parsed.variant); - }, this); + }, this); this.dispatch({type: 'excommand', command: commandstring, range: this}); }catch(err){ this.data.stderr(err); @@ -162,7 +160,7 @@ const addressRE = new RegExp('^\\s*' + // allow whitespace at the beginning ); // command names. Technically multiple >>> and <<< are legal, but we will treat them as parameters -const idRE = /^\s*([!=&~><]|[a-zA-Z]+)/; +const idRE = /^\s*([!=&~><]|[a-zA-Z]+)/; function parseCommand(command, defaultaddress){ return { @@ -171,7 +169,7 @@ function parseCommand(command, defaultaddress){ variant: parseVariant(), parameter: parseParameter() }; - + function parseAddresses(){ var addresses = [defaultaddress]; // basic addresses @@ -351,7 +349,7 @@ function pushRegister(text, register){ }else{ // unnamed register is the delete stack registers.unshift(text); - } + } } function popRegister (register){ @@ -390,7 +388,7 @@ function addListener (rng, ...handler){ function removeListeners (rng){ rng.element[exkey].forEach( handler => rng.dontlisten(...handler) ); } - + /*********************** the actual editing commands *********************************/ // a command is a function (parameter {String}, variant {Boolean}) @@ -408,7 +406,7 @@ var commands = bililiteRange.ex.commands = { }, c: 'change', - + cd: 'directory', change: function (parameter, variant){ @@ -420,7 +418,7 @@ var commands = bililiteRange.ex.commands = { // the test is variant XOR autoindent. the !'s turn booleany values to boolean, then != means XOR if (!variant != !this.data.autoindent) this.indent(indentation); }, - + chdir: 'directory', copy: function (parameter, variant){ @@ -447,9 +445,9 @@ var commands = bililiteRange.ex.commands = { }, 'delete': 'del', - + dir: 'directory', - + edit: function (parameter, variant){ if (this.data.confirm && this.data.savestatus == 'dirty' && !variant){ throw new Error (this.data.file + ' not saved. Use edit! to force reloading'); @@ -485,7 +483,7 @@ var commands = bililiteRange.ex.commands = { const addedlines = this.all().split('\n').length - oldlines; lines[1] += addedlines; i += addedlines; - // note that this assumes the added lines are all before or immediately after the current line. If not, we will skip the wrong lines + // note that this assumes the added lines are all before or immediately after the current line. If not, we will skip the wrong lines } } this.bounds(line).bounds('EOL'); // move to the end of the last modified line @@ -522,7 +520,7 @@ var commands = bililiteRange.ex.commands = { k: 'mark', m: 'move', - + map: function (parameter, variant){ const parameters = splitCommands (parameter, ' '); const lhs = string(parameters.shift()); @@ -556,7 +554,7 @@ var commands = bililiteRange.ex.commands = { }, print: function() { this.select() }, - + preserve () { preserve(this) }, put: function (parameter, variant){ @@ -565,7 +563,7 @@ var commands = bililiteRange.ex.commands = { ownline: true }).bounds('endbounds'); }, - + quit (parameter, variant){ const data = this.data; if (!variant && data.savestatus != 'clean' && data.confirm){ @@ -578,7 +576,7 @@ var commands = bililiteRange.ex.commands = { data.marks = {}; this.window.dispatchEvent( new CustomEvent('quit', { detail: this.element }) ); }, - + read: function (parameter, variant){ if (variant) { this.text(Function (parameter).call(this)); @@ -594,7 +592,7 @@ var commands = bililiteRange.ex.commands = { ); } }, - + recover () { recover(this) }, redo: function (parameter, variant){ @@ -603,7 +601,7 @@ var commands = bililiteRange.ex.commands = { }, s: 'substitute', - + sendkeys: function (parameter, variant){ this.sendkeys(parameter); }, @@ -634,7 +632,7 @@ var commands = bililiteRange.ex.commands = { }, shiftwidth: "tabsize", - + source: function (parameter, variant){ if (!parameter) throw new Error ('No file named in source'); this.data.reader(parameter, this.data.directory).then( sourcefile => { @@ -652,19 +650,19 @@ var commands = bililiteRange.ex.commands = { if (re.source == '' && re.replacement == '') re = lastSubstitutionRE; if (re.source == '') re.source = lastRE.source; this.replace(re, string(re.replacement)).bounds('EOL'); - lastSubstitutionRE = Object.assign({}, re); // clone, so + lastSubstitutionRE = Object.assign({}, re); // clone, so }, sw: 'tabsize', t: 'copy', - + tabstop: 'tabsize', transcribe: 'copy', ts: 'tabsize', - + unmap: function (parameter, variant){ this.dispatch ({type: 'map', detail: { command: 'unmap', variant, lhs: parameter }}); }, @@ -682,15 +680,15 @@ var commands = bililiteRange.ex.commands = { }, v: 'notglobal', - + version: function (parameter, variant){ this.data.stdout(this.element[exkey]); }, - + wq: 'xit', ws: 'wrapscan', - + xit: function(parameter, variant){ writer(this, parameter).finally( ()=> { if (variant || this.data.savestatus == 'clean'){ @@ -714,7 +712,7 @@ var commands = bililiteRange.ex.commands = { let lines = this.lines(); this.data.stdout ('['+(lines[0] == lines[1] ? lines[0] : lines[0]+', '+lines[1])+']'); }, - + '&': 'substitute', '~': function (parameter, variant){ @@ -722,19 +720,19 @@ var commands = bililiteRange.ex.commands = { lastSubstitutionRE.flags = ''; commands.substitute.call (this, parameter, variant); }, - + '>': function (parameter, variant){ parameter = parseInt(parameter); if (isNaN(parameter) || parameter < 0) parameter = 1; this.indent('\t'.repeat(parameter)); }, - + '<': function (parameter, variant){ parameter = parseInt(parameter); if (isNaN(parameter) || parameter < 0) parameter = 1; this.unindent(parameter, this.data.tabsize); }, - + '!': function (parameter, variant){ // not a shell escape but a Javascript escape Function (parameter).call(this); @@ -809,4 +807,4 @@ createOption ('wrapscan'); createOption ('directory', ''); createOption ('file', 'document'); -})(); \ No newline at end of file +module.exports = bililiteRange; diff --git a/bililiteRange.find.js b/bililiteRange.find.js index 8349054..d50f909 100644 --- a/bililiteRange.find.js +++ b/bililiteRange.find.js @@ -1,6 +1,4 @@ -'use strict'; - -(function(bililiteRange){ +const { bililiteRange } = require('./bililiteRange.js'); bililiteRange.createOption('dotall', {value: false}); bililiteRange.createOption('global', {value: false}); @@ -33,7 +31,7 @@ bililiteRange.prototype.replace = function (search, replace, flags = ''){ replaceprimitive (search, parseFlags(this, flags), this.all(), replace, this[0], this[1]), { inputType: 'insertReplacementText' } ); -} +} bililiteRange.createOption ('word', {value: /\b/}); bililiteRange.createOption ('bigword', {value: /\s+/}); @@ -200,4 +198,4 @@ function replaceprimitive (search, flagobject, text, replace, from, to){ return text.replace (re, replace).slice(from, to-text.length || undefined); } -})(bililiteRange); \ No newline at end of file +module.exports = bililiteRange; diff --git a/bililiteRange.js b/bililiteRange.js index a2bd502..485d1b8 100644 --- a/bililiteRange.js +++ b/bililiteRange.js @@ -1,23 +1,30 @@ -'use strict'; -let bililiteRange; // create one global variable +module.exports.default = bililiteRange; +module.exports.bililiteRange = bililiteRange; + + -(function(){ - const datakey = Symbol(); // use as the key to modify elements. -bililiteRange = function(el){ +/** +@template {HTMLInputElement} E +@param {E} el +@return {(typeof bililiteRange)['prototype'] & (E extends HTMLInputElement ? InputRange : E extends HTMLTextAreaElement ? W3CRange : NothingRange)} +*/ + +function bililiteRange(el) { + /**@type {Range} */ var ret; - if (el.setSelectionRange){ - // Element is an input or textarea + if (el.setSelectionRange) { + // Element is an input or textarea // note that some input elements do not allow selections - try{ + try { el.selectionStart = el.selectionStart; ret = new InputRange(); - }catch(e){ + } catch (e) { ret = new NothingRange(); } - }else{ + } else { // Standards, with any other kind of element ret = new W3CRange(); } @@ -26,152 +33,163 @@ bililiteRange = function(el){ ret._doc = el.ownerDocument; ret._win = ret._doc.defaultView; ret._bounds = [0, ret.length]; - - if (!(el[datakey])){ // we haven't processed this element yet - const data = createDataObject (el); - startupHooks.forEach ( hook => hook (el, ret, data) ); - } + if (!el[datakey]) { + // we haven't processed this element yet + const data = createDataObject(el); + startupHooks.forEach((hook) => hook(el, ret, data)); + } return ret; } -bililiteRange.version = 3.2; +bililiteRange.version = 5.0; const startupHooks = new Set(); -bililiteRange.addStartupHook = fn => startupHooks.add(fn); -startupHooks.add (trackSelection); -startupHooks.add (fixInputEvents); -startupHooks.add (correctNewlines); +bililiteRange.addStartupHook = (fn) => startupHooks.add(fn); +startupHooks.add(trackSelection); +startupHooks.add(fixInputEvents); +startupHooks.add(correctNewlines); // selection tracking. We want clicks to set the selection to the clicked location but tabbing in or element.focus() should restore // the selection to what it was. // There's no good way to do this. I just assume that a mousedown (or a drag and drop // into the element) within 100 ms of the focus event must have caused the focus, and // therefore we should not restore the selection. -function trackSelection (element, range, data){ - data.selection = [0,0]; - range.listen('focusout', evt => data.selection = range._nativeSelection() ); - range.listen('mousedown', evt => data.mousetime = evt.timeStamp ); - range.listen('drop', evt => data.mousetime = evt.timeStamp ); - range.listen('focus', evt => { - if ('mousetime' in data && evt.timeStamp - data.mousetime < 100) return; - range._nativeSelect(range._nativeRange(data.selection)) +function trackSelection(element, range, data) { + data.selection = [0, 0]; + range.listen( + "focusout", + (evt) => (data.selection = range._nativeSelection()) + ); + range.listen("mousedown", (evt) => (data.mousetime = evt.timeStamp)); + range.listen("drop", (evt) => (data.mousetime = evt.timeStamp)); + range.listen("focus", (evt) => { + if ("mousetime" in data && evt.timeStamp - data.mousetime < 100) return; + range._nativeSelect(range._nativeRange(data.selection)); }); } -function fixInputEvents (element, range, data){ +function fixInputEvents(element, range, data) { // DOM 3 input events, https://www.w3.org/TR/input-events-1/ // have a data field with the text inserted, but that isn't enough to fully describe the change; // we need to know the old text (or at least its length) // and *where* the new text was inserted. - // So we enhance input events with that information. + // So we enhance input events with that information. // the "newText" should always be the same as the 'data' field, if it is defined data.oldText = range.all(); data.liveRanges = new Set(); - range.listen('input', evt => { + range.listen("input", (evt) => { const newText = range.all(); - if (!evt.bililiteRange){ - evt.bililiteRange = diff (data.oldText, newText); - if (evt.bililiteRange.unchanged){ + if (!evt.bililiteRange) { + evt.bililiteRange = diff(data.oldText, newText); + if (evt.bililiteRange.unchanged) { // no change. Assume that whatever happened, happened at the selection point (and use whatever data the browser gives us). - evt.bililiteRange.start = range.clone().bounds('selection')[1] - (evt.data || '').length; + evt.bililiteRange.start = + range.clone().bounds("selection")[1] - (evt.data || "").length; } } data.oldText = newText; - + // Also update live ranges on this element - data.liveRanges.forEach( rng => { + data.liveRanges.forEach((rng) => { const start = evt.bililiteRange.start; const oldend = start + evt.bililiteRange.oldText.length; const newend = start + evt.bililiteRange.newText.length; // adjust bounds; this tries to emulate the algorithm that Microsoft Word uses for bookmarks let [b0, b1] = rng.bounds(); - if (b0 <= start){ + if (b0 <= start) { // no change - }else if (b0 > oldend){ - b0 += newend - oldend; - }else{ - b0 = newend; + } else if (b0 > oldend) { + b0 += newend - oldend; + } else { + b0 = newend; } - if (b1 < start){ + if (b1 < start) { // no change - }else if (b1 >= oldend){ + } else if (b1 >= oldend) { b1 += newend - oldend; - }else{ + } else { b1 = start; } - rng.bounds([b0, b1]); - }) + rng.bounds([b0, b1]); + }); }); } -function diff (oldText, newText){ +function diff(oldText, newText) { // Try to find the changed text, assuming it was a continuous change - if (oldText == newText){ + if (oldText == newText) { return { unchanged: true, start: 0, oldText, newText - } + }; } const oldlen = oldText.length; const newlen = newText.length; - for (var i = 0; i < newlen && i < oldlen; ++i){ + for (var i = 0; i < newlen && i < oldlen; ++i) { if (newText.charAt(i) != oldText.charAt(i)) break; } const start = i; - for (i = 0; i < newlen && i < oldlen; ++i){ - let newpos = newlen-i-1, oldpos = oldlen-i-1; + for (i = 0; i < newlen && i < oldlen; ++i) { + let newpos = newlen - i - 1, + oldpos = oldlen - i - 1; if (newpos < start || oldpos < start) break; if (newText.charAt(newpos) != oldText.charAt(oldpos)) break; } - const oldend = oldlen-i; - const newend = newlen-i; + const oldend = oldlen - i; + const newend = newlen - i; return { start, oldText: oldText.slice(start, oldend), newText: newText.slice(start, newend) - } -}; + }; +} bililiteRange.diff = diff; // expose -function correctNewlines (element, range, data){ +function correctNewlines(element, range, data) { // we need to insert newlines rather than create new elements, so character-based calculations work - range.listen('paste', evt => { + range.listen("paste", (evt) => { if (evt.defaultPrevented) return; // windows adds \r's to clipboard! - range.clone().bounds('selection'). - text(evt.clipboardData.getData("text/plain").replace(/\r/g,''), {inputType: 'insertFromPaste'}). - bounds('endbounds'). - select(). - scrollIntoView(); + range + .clone() + .bounds("selection") + .text(evt.clipboardData.getData("text/plain").replace(/\r/g, ""), { + inputType: "insertFromPaste" + }) + .bounds("endbounds") + .select() + .scrollIntoView(); evt.preventDefault(); }); - range.listen('keydown', function(evt){ + range.listen("keydown", function (evt) { if (evt.ctrlKey || evt.altKey || evt.shiftKey || evt.metaKey) return; if (evt.defaultPrevented) return; - if (evt.key == 'Enter'){ - range.clone().bounds('selection'). - text('\n', {inputType: 'insertLineBreak'}). - bounds('endbounds'). - select(). - scrollIntoView(); + if (evt.key == "Enter") { + range + .clone() + .bounds("selection") + .text("\n", { inputType: "insertLineBreak" }) + .bounds("endbounds") + .select() + .scrollIntoView(); evt.preventDefault(); } }); } // convenience function for defining input events -function inputEventInit(type, oldText, newText, start, inputType){ +function inputEventInit(type, oldText, newText, start, inputType) { return { type, inputType, data: newText, bubbles: true, bililiteRange: { - unchanged: (oldText == newText), + unchanged: oldText == newText, start, oldText, newText @@ -180,348 +198,420 @@ function inputEventInit(type, oldText, newText, start, inputType){ } // base class -function Range(){} -Range.prototype = { - // allow use of range[0] and range[1] for start and end of bounds - get 0(){ - return this.bounds()[0]; - }, - set 0(x){ - this.bounds([x, this[1]]); - return x; - }, - get 1(){ - return this.bounds()[1]; - }, - set 1(x){ - this.bounds([this[0], x]); - return x; - }, - all: function(text){ - if (arguments.length){ - return this.bounds('all').text(text, {inputType: 'insertReplacementText'}); - }else{ - return this._el[this._textProp]; - } - }, - bounds: function(s){ - if (typeof s === 'number'){ - this._bounds = [s,s]; - }else if (bililiteRange.bounds[s]){ - this.bounds(bililiteRange.bounds[s].apply(this, arguments)); - }else if (s && s.bounds){ - this._bounds = s.bounds(); // copy bounds from an existing range - }else if (s){ - this._bounds = s; // don't do error checking now; things may change at a moment's notice - }else{ - // constrain bounds now - var b = [ - Math.max(0, Math.min (this.length, this._bounds[0])), - Math.max(0, Math.min (this.length, this._bounds[1])) - ]; - b[1] = Math.max(b[0], b[1]); - return b; - } - return this; // allow for chaining - }, - clone: function(){ - return bililiteRange(this._el).bounds(this.bounds()); - }, - get data(){ - return this._el[datakey]; - }, - dispatch: function(opts = {}){ - var event = new Event (opts.type, opts); - event.view = this._win; - for (let prop in opts) try { event[prop] = opts[prop] } catch(e){}; // ignore read-only errors for properties that were copied in the previous line - this._el.dispatchEvent(event); // note that the event handlers will be called synchronously, before the "return this;" - return this; - }, - get document() { - return this._doc; - }, - dontlisten: function (type, func = console.log, target){ - target ??= this._el; - target.removeEventListener(type, func); - return this; - }, - get element() { - return this._el - }, - get length() { - return this._el[this._textProp].length; - }, - live (on = true){ - this.data.liveRanges[on ? 'add' : 'delete'](this); - return this; - }, - listen: function (type, func = console.log, target){ - target ??= this._el; - target.addEventListener(type, func); - return this; - }, - scrollIntoView() { - var top = this.top(); - // note that for TEXTAREA's, this.top() will do the scrolling and the following is irrelevant. - // scroll into position if necessary - if (this._el.scrollTop > top || this._el.scrollTop+this._el.clientHeight < top){ - this._el.scrollTop = top; - } - return this; - }, - select: function(){ - var b = this.data.selection = this.bounds(); - if (this._el === this._doc.activeElement){ - // only actually select if this element is active! - this._nativeSelect(this._nativeRange(b)); - } - this.dispatch({type: 'select', bubbles: true}); - return this; // allow for chaining - }, - selection: function(text){ - if (arguments.length){ - return this.bounds('selection').text(text).bounds('endbounds').select(); - }else{ - return this.bounds('selection').text(); - } - }, - sendkeys: function (text){ - this.data.sendkeysOriginalText = this.text(); - this.data.sendkeysBounds = undefined; - function simplechar (rng, c){ - if (/^{[^}]*}$/.test(c)) c = c.slice(1,-1); // deal with unknown {key}s - rng.text(c).bounds('endbounds'); - } - text.replace(/{[^}]*}|[^{]+|{/g, part => (bililiteRange.sendkeys[part] || simplechar)(this, part, simplechar) ); - this.bounds(this.data.sendkeysBounds); - this.dispatch({type: 'sendkeys', detail: text}); - return this; - }, - text: function(text, {inputType = 'insertText'} = {}){ - if ( text !== undefined ){ - let eventparams = [this.text(), text, this[0], inputType]; - this.dispatch (inputEventInit('beforeinput',...eventparams)); - this._nativeSetText(text, this._nativeRange(this.bounds())); - this[1] = this[0]+text.length; - this.dispatch (inputEventInit('input',...eventparams)); - return this; // allow for chaining - }else{ - return this._nativeGetText(this._nativeRange(this.bounds())); - } - }, - top: function(){ - return this._nativeTop(this._nativeRange(this.bounds())); - }, - get window() { - return this._win; - }, - wrap: function (n){ - this._nativeWrap(n, this._nativeRange(this.bounds())); - return this; - }, -}; +/** +@class +@abstract +@mixes W3CRange +@mixes InputRange +@mixes NothingRange +@template {HTMLElement} E +@param {E} el +*/ +class Range { + constructor(el) { + /**@type {E} */ + this._el = this._el||el; + // determine parent document, as implemented by John McLear + /**@type {Document} */ + this._doc = undefined; + /**@type {Window} */ + this._win = undefined; + /**@type {ArrayLike} */ + this._bounds = undefined; + + /**@type {keyof { [K in keyof E as E[K] extends string ? K : never]: E[K] }} */ + this._textProp = undefined; + } + // allow use of range[0] and range[1] for start and end of bounds + get 0() { + return this.bounds()[0]; + } + set 0(x) { + this.bounds([x, this[1]]); + return x; + } + get 1() { + return this.bounds()[1]; + } + set 1(x) { + this.bounds([this[0], x]); + return x; + } + all(text) { + if (arguments.length) { + return this.bounds("all").text(text, { + inputType: "insertReplacementText" + }); + } else { + return this._el[this._textProp]; + } + } + bounds(/**@type {number|[number, number]|Range|string|undefined} */ s) { + if (typeof s === "number") { + this._bounds = [s, s]; + } else if (bililiteRange.bounds[s]) { + this.bounds(bililiteRange.bounds[s].apply(this, arguments)); + } else if (s && s.bounds) { + this._bounds = s.bounds(); // copy bounds from an existing range + } else if (s) { + this._bounds = s; // don't do error checking now; things may change at a moment's notice + } else { + // constrain bounds now + var b = [ + Math.max(0, Math.min(this.length, this._bounds[0])), + Math.max(0, Math.min(this.length, this._bounds[1])) + ]; + b[1] = Math.max(b[0], b[1]); + return b; + } + return this; // allow for chaining + } + clone() { + return bililiteRange(this._el).bounds(this.bounds()); + } + get data() { + return this._el[datakey]; + } + dispatch(opts = {}) { + var event = new Event(opts.type, opts); + event.view = this._win; + for (let prop in opts) + try { + event[prop] = opts[prop]; + } catch (e) { } // ignore read-only errors for properties that were copied in the previous line + this._el.dispatchEvent(event); // note that the event handlers will be called synchronously, before the "return this;" + return this; + } + get document() { + return this._doc; + } + dontlisten(type, func = console.log, target) { + target ??= this._el; + target.removeEventListener(type, func); + return this; + } + get element() { + return this._el; + } + get length() { + return this._el[this._textProp].length; + } + live(on = true) { + this.data.liveRanges[on ? "add" : "delete"](this); + return this; + } + listen(type, func = console.log, target) { + target ??= this._el; + target.addEventListener(type, func); + return this; + } + scrollIntoView() { + var top = this.top(); + // note that for TEXTAREA's, this.top() will do the scrolling and the following is irrelevant. + // scroll into position if necessary + if (this._el.scrollTop > top || + this._el.scrollTop + this._el.clientHeight < top) { + this._el.scrollTop = top; + } + return this; + } + select() { + var b = (this.data.selection = this.bounds()); + if (this._el === this._doc.activeElement) { + // only actually select if this element is active! + this._nativeSelect(this._nativeRange(b)); + } + this.dispatch({ type: "select", bubbles: true }); + return this; // allow for chaining + } + selection(text) { + if (arguments.length) { + return this.bounds("selection").text(text).bounds("endbounds").select(); + } else { + return this.bounds("selection").text(); + } + } + sendkeys(text) { + this.data.sendkeysOriginalText = this.text(); + this.data.sendkeysBounds = undefined; + function simplechar(rng, c) { + if (/^{[^}]*}$/.test(c)) c = c.slice(1, -1); // deal with unknown {key}s + rng.text(c).bounds("endbounds"); + } + text.replace(/{[^}]*}|[^{]+|{/g, (part) => (bililiteRange.sendkeys[part] || simplechar)(this, part, simplechar) + ); + this.bounds(this.data.sendkeysBounds); + this.dispatch({ type: "sendkeys", detail: text }); + return this; + } + text(text, { inputType = "insertText" } = {}) { + if (text !== undefined) { + let eventparams = [this.text(), text, this[0], inputType]; + this.dispatch(inputEventInit("beforeinput", ...eventparams)); + this._nativeSetText(text, this._nativeRange(this.bounds())); + this[1] = this[0] + text.length; + this.dispatch(inputEventInit("input", ...eventparams)); + return this; // allow for chaining + } else { + return this._nativeGetText(this._nativeRange(this.bounds())); + } + } + top() { + return this._nativeTop(this._nativeRange(this.bounds())); + } + get window() { + return this._win; + } + wrap(n) { + this._nativeWrap(n, this._nativeRange(this.bounds())); + return this; + } +} // allow extensions ala jQuery bililiteRange.prototype = Range.prototype; -bililiteRange.extend = function(fns){ +bililiteRange.extend = function extend(fns) { Object.assign(bililiteRange.prototype, fns); }; bililiteRange.override = (name, fn) => { const oldfn = bililiteRange.prototype[name]; - bililiteRange.prototype[name] = function(){ + bililiteRange.prototype[name] = function () { const oldsuper = this.super; this.super = oldfn; const ret = fn.apply(this, arguments); this.super = oldsuper; return ret; }; -} +}; //bounds functions bililiteRange.bounds = { - all: function() { return [0, this.length] }, - start: function() { return 0 }, - end: function() { return this.length }, - selection: function() { - if (this._el === this._doc.activeElement){ - this.bounds ('all'); // first select the whole thing for constraining + all() { + return [0, this.length]; + }, + start() { + return 0; + }, + end() { + return this.length; + }, + selection() { + if (this._el === this._doc.activeElement) { + this.bounds("all"); // first select the whole thing for constraining return this._nativeSelection(); - }else{ + } else { return this.data.selection; } }, - startbounds: function() { return this[0] }, - endbounds: function() { return this[1] }, - union: function (name,...rest) { + startbounds() { + return this[0]; + }, + endbounds() { + return this[1]; + }, + union(name, ...rest) { const b = this.clone().bounds(...rest); - return [ Math.min(this[0], b[0]), Math.max(this[1], b[1]) ]; + return [Math.min(this[0], b[0]), Math.max(this[1], b[1])]; }, - intersection: function (name,...rest) { + intersection(name, ...rest) { const b = this.clone().bounds(...rest); - return [ Math.max(this[0], b[0]), Math.min(this[1], b[1]) ]; + return [Math.max(this[0], b[0]), Math.min(this[1], b[1])]; } }; // sendkeys functions bililiteRange.sendkeys = { - '{tab}': function (rng, c, simplechar){ - simplechar(rng, '\t'); // useful for inserting what would be whitespace + "{tab}"(rng, c, simplechar) { + simplechar(rng, "\t"); // useful for inserting what would be whitespace }, - '{newline}': function (rng){ - rng.text('\n', {inputType: 'insertLineBreak'}).bounds('endbounds'); + "{newline}"(rng) { + rng.text("\n", { inputType: "insertLineBreak" }).bounds("endbounds"); }, - '{backspace}': function (rng){ - var b = rng.bounds(); - if (b[0] == b[1]) rng.bounds([b[0]-1, b[0]]); // no characters selected; it's just an insertion point. Remove the previous character - rng.text('', {inputType: 'deleteContentBackward'}); // delete the characters and update the selection + "{backspace}"(rng) { + const b = rng.bounds(); + if (b[0] == b[1]) rng.bounds([b[0] - 1, b[0]]); // no characters selected; it's just an insertion point. Remove the previous character + rng.text("", { inputType: "deleteContentBackward" }); // delete the characters and update the selection }, - '{del}': function (rng){ - var b = rng.bounds(); - if (b[0] == b[1]) rng.bounds([b[0], b[0]+1]); // no characters selected; it's just an insertion point. Remove the next character - rng.text('', {inputType: 'deleteContentForward'}).bounds('endbounds'); // delete the characters and update the selection + "{del}"(rng) { + const b = rng.bounds(); + if (b[0] == b[1]) rng.bounds([b[0], b[0] + 1]); // no characters selected; it's just an insertion point. Remove the next character + rng.text("", { inputType: "deleteContentForward" }).bounds("endbounds"); // delete the characters and update the selection }, - '{rightarrow}': function (rng){ - var b = rng.bounds(); + "{rightarrow}"(rng) { + const b = rng.bounds(); if (b[0] == b[1]) ++b[1]; // no characters selected; it's just an insertion point. Move to the right rng.bounds([b[1], b[1]]); }, - '{leftarrow}': function (rng){ - var b = rng.bounds(); + "{leftarrow}"(rng) { + const b = rng.bounds(); if (b[0] == b[1]) --b[0]; // no characters selected; it's just an insertion point. Move to the left rng.bounds([b[0], b[0]]); }, - '{selectall}': function (rng){ - rng.bounds('all'); + "{selectall}"(rng) { + rng.bounds("all"); }, - '{selection}': function (rng){ + "{selection}"(rng) { // insert the characters without the sendkeys processing - rng.text(rng.data.sendkeysOriginalText).bounds('endbounds'); + rng.text(rng.data.sendkeysOriginalText).bounds("endbounds"); }, - '{mark}': function (rng){ + "{mark}"(rng) { rng.data.sendkeysBounds = rng.bounds(); }, - '{ctrl-Home}': (rng, c, simplechar) => rng.bounds('start'), - '{ctrl-End}': (rng, c, simplechar) => rng.bounds('end') + "{ctrl-Home}": (rng, c, simplechar) => rng.bounds("start"), + "{ctrl-End}": (rng, c, simplechar) => rng.bounds("end") }; // Synonyms from the DOM standard (http://www.w3.org/TR/DOM-Level-3-Events-key/) -bililiteRange.sendkeys['{Enter}'] = bililiteRange.sendkeys['{enter}'] = bililiteRange.sendkeys['{newline}']; -bililiteRange.sendkeys['{Backspace}'] = bililiteRange.sendkeys['{backspace}']; -bililiteRange.sendkeys['{Delete}'] = bililiteRange.sendkeys['{del}']; -bililiteRange.sendkeys['{ArrowRight}'] = bililiteRange.sendkeys['{rightarrow}']; -bililiteRange.sendkeys['{ArrowLeft}'] = bililiteRange.sendkeys['{leftarrow}']; - -// an input element in a standards document. "Native Range" is just the bounds array -function InputRange(){} -InputRange.prototype = new Range(); -InputRange.prototype._textProp = 'value'; -InputRange.prototype._nativeRange = function(bounds) { - return bounds || [0, this.length]; -}; -InputRange.prototype._nativeSelect = function (rng){ - this._el.setSelectionRange(rng[0], rng[1]); -}; -InputRange.prototype._nativeSelection = function(){ - return [this._el.selectionStart, this._el.selectionEnd]; -}; -InputRange.prototype._nativeGetText = function(rng){ - return this._el.value.substring(rng[0], rng[1]); -}; -InputRange.prototype._nativeSetText = function(text, rng){ - var val = this._el.value; - this._el.value = val.substring(0, rng[0]) + text + val.substring(rng[1]); -}; -InputRange.prototype._nativeEOL = function(){ - this.text('\n'); -}; -InputRange.prototype._nativeTop = function(rng){ - if (rng[0] == 0) return 0; // the range starts at the top - const el = this._el; - if (el.nodeName == 'INPUT') return 0; - const text = el.value; - const selection = [el.selectionStart, el.selectionEnd]; - // hack from https://code.google.com/archive/p/proveit-js/source/default/source, highlightLengthAtIndex function - // note that this results in the element being scrolled; the actual number returned is irrelevant - el.value = text.slice(0, rng[0]); - el.scrollTop = Number.MAX_SAFE_INTEGER; - el.value = text; - el.setSelectionRange(...selection); - return el.scrollTop; +bililiteRange.sendkeys["{Enter}"] = bililiteRange.sendkeys["{enter}"] = + bililiteRange.sendkeys["{newline}"]; +bililiteRange.sendkeys["{Backspace}"] = bililiteRange.sendkeys["{backspace}"]; +bililiteRange.sendkeys["{Delete}"] = bililiteRange.sendkeys["{del}"]; +bililiteRange.sendkeys["{ArrowRight}"] = bililiteRange.sendkeys["{rightarrow}"]; +bililiteRange.sendkeys["{ArrowLeft}"] = bililiteRange.sendkeys["{leftarrow}"]; + +/** +an input element in a standards document. "Native Range" is just the bounds array +@class +@template {HTMLInputElement} E +@extends Range +*/ +class InputRange extends Range { + /**@param {E|undefined} el */ + constructor(el) { + super(el); + this._textProp = "value"; + } + + _nativeRange(bounds) { + return bounds || [0, this.length]; + } + + _nativeSelect(rng) { + this._el.setSelectionRange(rng[0], rng[1]); + } + + _nativeSelection() { + return [this._el.selectionStart, this._el.selectionEnd]; + } + + _nativeGetText(rng) { + return this._el.value.substring(rng[0], rng[1]); + } + + _nativeSetText(text, rng) { + const val = this._el.value; + this._el.value = val.substring(0, rng[0]) + text + val.substring(rng[1]); + } + + _nativeEOL() { + this.text("\n"); + } + + _nativeTop(rng) { + if (rng[0] == 0) return 0; // the range starts at the top + const el = this._el; + if (el.nodeName == "INPUT") return 0; + const text = el.value; + const selection = [el.selectionStart, el.selectionEnd]; + // hack from https://code.google.com/archive/p/proveit-js/source/default/source, highlightLengthAtIndex function + // note that this results in the element being scrolled; the actual number returned is irrelevant + el.value = text.slice(0, rng[0]); + el.scrollTop = Number.MAX_SAFE_INTEGER; + el.value = text; + el.setSelectionRange(...selection); + return el.scrollTop; + } + + _nativeWrap() { + throw new Error("Cannot wrap in a text element"); + } } -InputRange.prototype._nativeWrap = function() {throw new Error("Cannot wrap in a text element")}; - -function W3CRange(){} -W3CRange.prototype = new Range(); -W3CRange.prototype._textProp = 'textContent'; -W3CRange.prototype._nativeRange = function (bounds){ - var rng = this._doc.createRange(); - rng.selectNodeContents(this._el); - if (bounds){ - w3cmoveBoundary (rng, bounds[0], true, this._el); - rng.collapse (true); - w3cmoveBoundary (rng, bounds[1]-bounds[0], false, this._el); - } - return rng; -}; -W3CRange.prototype._nativeSelect = function (rng){ - this._win.getSelection().removeAllRanges(); - this._win.getSelection().addRange (rng); -}; -W3CRange.prototype._nativeSelection = function (){ - // returns [start, end] for the selection constrained to be in element - var rng = this._nativeRange(); // range of the element to constrain to - if (this._win.getSelection().rangeCount == 0) return [this.length, this.length]; // append to the end - var sel = this._win.getSelection().getRangeAt(0); - return [ - w3cstart(sel, rng), - w3cend (sel, rng) - ]; -}; -W3CRange.prototype._nativeGetText = function (rng){ - return rng.toString(); -}; -W3CRange.prototype._nativeSetText = function (text, rng){ - rng.deleteContents(); - rng.insertNode (this._doc.createTextNode(text)); - // Lea Verou's "super dirty fix" to #31 - if(text == '\n' && this[1]+1 == this._el.textContent.length) { - // inserting a newline at the end - this._el.innerHTML = this._el.innerHTML + '\n'; - } - this._el.normalize(); // merge the text with the surrounding text - }; -W3CRange.prototype._nativeEOL = function(){ - var rng = this._nativeRange(this.bounds()); - rng.deleteContents(); - var br = this._doc.createElement('br'); - br.setAttribute ('_moz_dirty', ''); // for Firefox - rng.insertNode (br); - rng.insertNode (this._doc.createTextNode('\n')); - rng.collapse (false); -}; -W3CRange.prototype._nativeTop = function(rng){ - if (this.length == 0) return 0; // no text, no scrolling - if (rng.toString() == ''){ - var textnode = this._doc.createTextNode('X'); - rng.insertNode (textnode); - } - var startrng = this._nativeRange([0,1]); - var top = rng.getBoundingClientRect().top - startrng.getBoundingClientRect().top; - if (textnode) textnode.parentNode.removeChild(textnode); - return top; + +/** +@class +@template {HTMLTextAreaElement} E +@extends Range +*/ +class W3CRange extends Range { + /**@param {E|undefined} el */ + constructor(el) { + super(el); + this._textProp = "textContent"; + } + + _nativeRange(bounds) { + const rng = this._doc.createRange(); + rng.selectNodeContents(this._el); + if (bounds) { + w3cmoveBoundary(rng, bounds[0], true, this._el); + rng.collapse(true); + w3cmoveBoundary(rng, bounds[1] - bounds[0], false, this._el); + } + return rng; + } + + _nativeSelect(rng) { + this._win.getSelection().removeAllRanges(); + this._win.getSelection().addRange(rng); + } + + _nativeSelection() { + const rng = this._nativeRange(); + if (this._win.getSelection().rangeCount == 0) + return [this.length, this.length]; + const sel = this._win.getSelection().getRangeAt(0); + return [w3cstart(sel, rng), w3cend(sel, rng)]; + } + + _nativeGetText(rng) { + return rng.toString(); + } + + _nativeSetText(text, rng) { + rng.deleteContents(); + rng.insertNode(this._doc.createTextNode(text)); + if (text == "\n" && this[1] + 1 == this._el.textContent.length) { + this._el.innerHTML = this._el.innerHTML + "\n"; + } + this._el.normalize(); + } + + _nativeEOL() { + const rng = this._nativeRange(this.bounds()); + rng.deleteContents(); + const br = this._doc.createElement("br"); + br.setAttribute("_moz_dirty", ""); + rng.insertNode(br); + rng.insertNode(this._doc.createTextNode("\n")); + rng.collapse(false); + } + + _nativeTop(rng) { + if (this.length == 0) return 0; + if (rng.toString() == "") { + var textnode = this._doc.createTextNode("X"); + rng.insertNode(textnode); + } + const startrng = this._nativeRange([0, 1]); + const top = + rng.getBoundingClientRect().top - startrng.getBoundingClientRect().top; + if (textnode) textnode.parentNode.removeChild(textnode); + return top; + } + + _nativeWrap(n, rng) { + rng.surroundContents(n); + } } -W3CRange.prototype._nativeWrap = function(n, rng) { - rng.surroundContents(n); -}; // W3C internals -function nextnode (node, root){ +function nextnode(node, root) { // in-order traversal // we've already visited node, so get kids then siblings if (node.firstChild) return node.firstChild; if (node.nextSibling) return node.nextSibling; - if (node===root) return null; - while (node.parentNode){ + if (node === root) return null; + while (node.parentNode) { // get uncles node = node.parentNode; if (node == root) return null; @@ -529,112 +619,145 @@ function nextnode (node, root){ } return null; } -function w3cmoveBoundary (rng, n, bStart, el){ +function w3cmoveBoundary(rng, n, bStart, el) { // move the boundary (bStart == true ? start : end) n characters forward, up to the end of element el. Forward only! // if the start is moved after the end, then an exception is raised if (n <= 0) return; - var node = rng[bStart ? 'startContainer' : 'endContainer']; - if (node.nodeType == 3){ - // we may be starting somewhere into the text - n += rng[bStart ? 'startOffset' : 'endOffset']; + var node = rng[bStart ? "startContainer" : "endContainer"]; + if (node.nodeType == 3) { + // we may be starting somewhere into the text + n += rng[bStart ? "startOffset" : "endOffset"]; } - while (node){ - if (node.nodeType == 3){ - var length = node.nodeValue.length; - if (n <= length){ - rng[bStart ? 'setStart' : 'setEnd'](node, n); + while (node) { + if (node.nodeType == 3) { + const length = node.nodeValue.length; + if (n <= length) { + rng[bStart ? "setStart" : "setEnd"](node, n); // special case: if we end next to a
, include that node. - if (n == length){ + if (n == length) { + let next; // skip past zero-length text nodes - for (var next = nextnode (node, el); next && next.nodeType==3 && next.nodeValue.length == 0; next = nextnode(next, el)){ - rng[bStart ? 'setStartAfter' : 'setEndAfter'](next); + for ( + next = nextnode(node, el); + next && next.nodeType == 3 && next.nodeValue.length == 0; + next = nextnode(next, el) + ) { + rng[bStart ? "setStartAfter" : "setEndAfter"](next); } - if (next && next.nodeType == 1 && next.nodeName == "BR") rng[bStart ? 'setStartAfter' : 'setEndAfter'](next); + if (next && next.nodeType == 1 && next.nodeName == "BR") + rng[bStart ? "setStartAfter" : "setEndAfter"](next); } return; - }else{ - rng[bStart ? 'setStartAfter' : 'setEndAfter'](node); // skip past this one + } else { + rng[bStart ? "setStartAfter" : "setEndAfter"](node); // skip past this one n -= length; // and eat these characters } } - node = nextnode (node, el); + node = nextnode(node, el); } } -var START_TO_START = 0; // from the w3c definitions -var START_TO_END = 1; -var END_TO_END = 2; -var END_TO_START = 3; +const START_TO_START = 0; // from the w3c definitions +const START_TO_END = 1; +const END_TO_END = 2; +const END_TO_START = 3; // from the Mozilla documentation, for range.compareBoundaryPoints(how, sourceRange) -// -1, 0, or 1, indicating whether the corresponding boundary-point of range is respectively before, equal to, or after the corresponding boundary-point of sourceRange. - // * Range.END_TO_END compares the end boundary-point of sourceRange to the end boundary-point of range. - // * Range.END_TO_START compares the end boundary-point of sourceRange to the start boundary-point of range. - // * Range.START_TO_END compares the start boundary-point of sourceRange to the end boundary-point of range. - // * Range.START_TO_START compares the start boundary-point of sourceRange to the start boundary-point of range. -function w3cstart(rng, constraint){ - if (rng.compareBoundaryPoints (START_TO_START, constraint) <= 0) return 0; // at or before the beginning - if (rng.compareBoundaryPoints (END_TO_START, constraint) >= 0) return constraint.toString().length; +// -1, 0, or 1, indicating whether the corresponding boundary-point of range is respectively before, equal to, or after the corresponding boundary-point of sourceRange. +// * Range.END_TO_END compares the end boundary-point of sourceRange to the end boundary-point of range. +// * Range.END_TO_START compares the end boundary-point of sourceRange to the start boundary-point of range. +// * Range.START_TO_END compares the start boundary-point of sourceRange to the end boundary-point of range. +// * Range.START_TO_START compares the start boundary-point of sourceRange to the start boundary-point of range. +function w3cstart(rng, constraint) { + if (rng.compareBoundaryPoints(START_TO_START, constraint) <= 0) return 0; // at or before the beginning + if (rng.compareBoundaryPoints(END_TO_START, constraint) >= 0) + return constraint.toString().length; rng = rng.cloneRange(); // don't change the original - rng.setEnd (constraint.endContainer, constraint.endOffset); // they now end at the same place + rng.setEnd(constraint.endContainer, constraint.endOffset); // they now end at the same place return constraint.toString().length - rng.toString().length; } -function w3cend (rng, constraint){ - if (rng.compareBoundaryPoints (END_TO_END, constraint) >= 0) return constraint.toString().length; // at or after the end - if (rng.compareBoundaryPoints (START_TO_END, constraint) <= 0) return 0; +function w3cend(rng, constraint) { + if (rng.compareBoundaryPoints(END_TO_END, constraint) >= 0) + return constraint.toString().length; // at or after the end + if (rng.compareBoundaryPoints(START_TO_END, constraint) <= 0) return 0; rng = rng.cloneRange(); // don't change the original - rng.setStart (constraint.startContainer, constraint.startOffset); // they now start at the same place + rng.setStart(constraint.startContainer, constraint.startOffset); // they now start at the same place return rng.toString().length; } -function NothingRange(){} -NothingRange.prototype = new Range(); -NothingRange.prototype._textProp = 'value'; -NothingRange.prototype._nativeRange = function(bounds) { - return bounds || [0,this.length]; -}; -NothingRange.prototype._nativeSelect = function (rng){ // do nothing -}; -NothingRange.prototype._nativeSelection = function(){ - return [0,0]; -}; -NothingRange.prototype._nativeGetText = function (rng){ - return this._el[this._textProp].substring(rng[0], rng[1]); -}; -NothingRange.prototype._nativeSetText = function (text, rng){ - var val = this._el[this._textProp]; - this._el[this._textProp] = val.substring(0, rng[0]) + text + val.substring(rng[1]); -}; -NothingRange.prototype._nativeEOL = function(){ - this.text('\n'); -}; -NothingRange.prototype._nativeTop = function(){ - return 0; -}; -NothingRange.prototype._nativeWrap = function() {throw new Error("Wrapping not implemented")}; +/** +@class +@extends Range +*/ +class NothingRange extends Range { + constructor() { + super(); + this._textProp = "value"; + } + + _nativeRange(bounds) { + return bounds || [0, this.length]; + } + + _nativeSelect(rng) { + // do nothing + } + + _nativeSelection() { + return [0, 0]; + } + + _nativeGetText(rng) { + return this._el[this._textProp].substring(rng[0], rng[1]); + } + + _nativeSetText(text, rng) { + var val = this._el[this._textProp]; + this._el[this._textProp] = + val.substring(0, rng[0]) + text + val.substring(rng[1]); + } + + _nativeEOL() { + this.text("\n"); + } + _nativeTop() { + return 0; + } + + _nativeWrap() { + throw new Error("Wrapping not implemented"); + } +} // data for elements, similar to jQuery data, but allows for monitoring with custom events const monitored = new Set(); -function signalMonitor(prop, value, element){ +function signalMonitor(prop, value, element) { const attr = `data-${prop}`; - element.dispatchEvent(new CustomEvent(attr, {bubbles: true, detail: value})); - try{ - element.setAttribute (attr, value); // illegal attribute names will throw. Ignore it - } finally { /* ignore */ } + element.dispatchEvent( + new CustomEvent(attr, { bubbles: true, detail: value }) + ); + try { + element.setAttribute(attr, value); // illegal attribute names will throw. Ignore it + } finally { + /* ignore */ + } } -function createDataObject (el){ - return el[datakey] = new Proxy(new Data(el), { +function createDataObject(el) { + return (el[datakey] = new Proxy(new Data(el), { set(obj, prop, value) { obj[prop] = value; if (monitored.has(prop)) signalMonitor(prop, value, obj.sourceElement); - return true; // in strict mode, 'set' returns a success flag + return true; // in strict mode, 'set' returns a success flag } - }); + })); } -var Data = function(el) { - Object.defineProperty(this, 'sourceElement', { +/** + @class + */ +function Data(el) { + Object.defineProperty(this, "sourceElement", { value: el }); } @@ -643,35 +766,46 @@ Data.prototype = {}; // for use with ex options. JSON.stringify(range.data) should return only the options that were // both defined with bililiteRange.option() *and* actually had a value set on this particular data object. // JSON.stringify (range.data.all) should return all the options that were defined. -Object.defineProperty(Data.prototype, 'toJSON', { - value: function(){ - let ret = {}; - for (let key in Data.prototype) if (this.hasOwnProperty(key)) ret[key] = this[key]; - return ret; - } -}); -Object.defineProperty(Data.prototype, 'all', { - get: function(){ - let ret = {}; - for (let key in Data.prototype) ret[key] = this[key]; - return ret; - } -}); -Object.defineProperty(Data.prototype, 'trigger', { - value: function(){ - monitored.forEach(prop => signalMonitor (prop, this[prop], this.sourceElement)); - } +Object.defineProperties(Data.prototype, { + toJSON: { + value() { + let ret = {}; + for (let key in Data.prototype) + if (this.hasOwnProperty(key)) ret[key] = this[key]; + return ret; + } + }, + all: { + get() { + let ret = {}; + for (let key in Data.prototype) ret[key] = this[key]; + return ret; + } + }, + trigger: { + value() { + monitored.forEach((prop) => + signalMonitor(prop, this[prop], this.sourceElement) + ); + } + } }); -bililiteRange.createOption = function (name, desc = {}){ - desc = Object.assign({ - enumerable: true, // use these as the defaults - writable: true, - configurable: true - }, Object.getOwnPropertyDescriptor(Data.prototype, name), desc); - if ('monitored' in desc) monitored[desc.monitored ? 'add' : 'delete'](name); +bililiteRange.createOption = function createOption(name, desc = {}) { + desc = Object.assign( + { + enumerable: true, // use these as the defaults + writable: true, + configurable: true + }, + Object.getOwnPropertyDescriptor(Data.prototype, name), + desc + ); + if ("monitored" in desc) monitored[desc.monitored ? "add" : "delete"](name); Object.defineProperty(Data.prototype, name, desc); return Data.prototype[name]; // return the default value -} +}; -})(); +module.exports.Range = Range; +module.exports.InputRange = InputRange; +module.exports.W3CRange = W3CRange; diff --git a/bililiteRange.lines.js b/bililiteRange.lines.js index 4af4ec9..e831d48 100644 --- a/bililiteRange.lines.js +++ b/bililiteRange.lines.js @@ -1,6 +1,6 @@ -'use strict'; +const { bililiteRange } = require('./bililiteRange.js'); + -(function(){ // a line goes from after the newline to before the next newline. The newline is not included in that line! It's // a separator only. bililiteRange.bounds.EOL = function () { @@ -136,4 +136,4 @@ function unindent(str, count, tabsize){ return str.replace(restart, '').replace(remiddle, '$1'); } -})(); \ No newline at end of file +module.exports = bililiteRange; diff --git a/bililiteRange.undo.js b/bililiteRange.undo.js index 345f6e7..ff323f9 100644 --- a/bililiteRange.undo.js +++ b/bililiteRange.undo.js @@ -1,5 +1,4 @@ -'use strict'; -(function(){ +const { bililiteRange } = require('./bililiteRange.js'); function keyhandler(evt){ if (!evt.ctrlKey || evt.altKey || evt.shiftKey || evt.metaKey) return; @@ -55,4 +54,4 @@ bililiteRange.extend({ } }); -})(); +module.exports = bililiteRange; diff --git a/module-support.js b/module-support.js new file mode 100644 index 0000000..bce3b7d --- /dev/null +++ b/module-support.js @@ -0,0 +1,67 @@ +'use strict'; + +((target, fakeloader, esmodules) =>{ + const mod = fakeloader(esmodules); + const exported = mod.exports.default; + target[exported.name] = exported; + +})( +(()=>{ + if (typeof module !== 'undefined' && typeof module === 'object' && module) { + if (!module.exports || typeof module.exports !== 'object') { + if (typeof exports !== 'undefined' && exports) { + module.exports = exports; + } else { + module.exports = {}; + } + } + return module.exports; + + } else if (typeof globalThis !== 'undefined' && globalThis) { + return globalThis; + + } else if (typeof window !== 'undefined' && window) { + return window; + } +})(), +function fakeloader(esmodules){ + let exports = { + get default() { return exports; }, + set default(value) { + const current = Object.getOwnPropertyDescriptors(exports); + Object.defineProperties(value, current); + + if (typeof value === 'function') { + if (!value.name) { + throw new Error('default export must be named'); + } + if (typeof current === 'function') { + throw new Error('Only export default a single function, as the name of that one function will be what is exported'); + } + value[value.name] = value; + } + exports = value; + } + }; + const module = { + get exports() { return exports; }, + set exports(value) { exports = value; }, + }; + + function require(name) { + return module.exports; + } + + for (let esmodule of esmodules) { + if (!esmodule) { continue; } + esmodule(require, module); + } + + return module; +}, +[ +// in the form of: +// (require, module) => { the code }, +/**###INSERT_FAKE_ES_MODULE_CODE_HERE###**/ + +]); From bcf2d15bfac598a972f4df841c623f667b7b7750 Mon Sep 17 00:00:00 2001 From: Derek Ziemba Date: Thu, 9 May 2024 11:52:27 -0500 Subject: [PATCH 3/6] feat(npm): ability to build as a node module & import with npm --- bower.json | 2 +- package.json | 12 +++++- package.ps1 | 113 ++++++++++++++++++++++++++++++++------------------- 3 files changed, 82 insertions(+), 45 deletions(-) diff --git a/bower.json b/bower.json index 96b415f..047d281 100644 --- a/bower.json +++ b/bower.json @@ -1,6 +1,6 @@ { "name": "bililiteRange", - "version": "3.2", + "version": "5.0.0", "main": "bililiteRange.js", "ignore": [ "test/**/*" diff --git a/package.json b/package.json index 984ea23..52120f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dwachss/bililite-range", - "version": "4.0.1", + "version": "5.0.0", "description": "bililiteRange is a javascript library that abstracts text selection and replacement.", "keywords": [ "sendkeys", @@ -12,9 +12,14 @@ "email": "d.wachss@prodigy.net", "url": "https://bililite.com/blog" }, + "contributors": [ + { + "name": "Derek Ziemba" + } + ], "repository": { "type": "git", - "url": "https://github.com/dwachss/bililiteRange.git" + "url": "https://github.com/DerekZiemba/bililiteRange.git" }, "license": "MIT", "homepage": "https://github.com/dwachss/bililiteRange", @@ -30,6 +35,9 @@ "scripts": { "build": "pwsh ./package.ps1 -Local" }, + "volta": { + "node": "22.1.0" + }, "dependencies": { }, diff --git a/package.ps1 b/package.ps1 index cdfc596..928ca4f 100644 --- a/package.ps1 +++ b/package.ps1 @@ -4,21 +4,23 @@ Param( # concatenates files $targets = @{ - "editor.js" = @( - "dwachss/historystack/history.js", - "dwachss/keymap/keymap.js", - "dwachss/status/status.js", - "dwachss/toolbar/toolbar.js", - "bililiteRange.js", - "bililiteRange.undo.js", - "bililiteRange.lines.js", - "bililiteRange.find.js", - "bililiteRange.ex.js", - "bililiteRange.evim.js" - ); + # "editor.js" = @( + # "dwachss/historystack/history.js", + # "dwachss/keymap/keymap.js", + # "dwachss/status/status.js", + # "dwachss/toolbar/toolbar.js", + # "bililiteRange.js", + # "bililiteRange.undo.js", + # "bililiteRange.lines.js", + # "bililiteRange.find.js", + # "bililiteRange.ex.js", + # "bililiteRange.evim.js" + # ); "bililiteRange.js" = @( "bililiteRange.js", - "bililiteRange.find.js" + "bililiteRange.find.js", + "bililiteRange.lines.js", + "jquery.sendkeys.js" ) } @@ -34,9 +36,19 @@ function Get-Sha { if ($repo) { $url = "https://api.github.com/repos/$repo/commits/master" $headers = @{Accept = 'application/vnd.github.sha'} - $req = (Invoke-WebRequest $url -Headers $headers) - $content = $req.Content[0..$shaSize] - [char[]]($content) -join '' + $pref = $ErrorActionPreference + try { + $ErrorActionPreference = 'Stop' + $req = (Invoke-WebRequest $url -Headers $headers) + $content = $req.Content[0..$shaSize] + [char[]]($content) -join '' + } catch { + $ErrorActionPreference = 'Continue' + Write-Error $_ + git rev-parse --short=$shaSize HEAD + } finally { + $ErrorActionPreference = $pref + } }else{ git rev-parse --short=$shaSize HEAD } @@ -123,7 +135,6 @@ function Wrap-Content { "/$('*'*49)", $hashlines, " $('*'*48)/", - "", $Content, "" ) | % { $_ } @@ -131,34 +142,52 @@ function Wrap-Content { } +function Concat-Modules { + $PLACEHOLDER = '/**###INSERT_FAKE_ES_MODULE_CODE_HERE###**/' + $wrapper = (Get-Content './module-support.js') -join "`n" -foreach ($target in $targets.Keys){ - $path = "dist/$target" - $targetLines = [System.Collections.Generic.List[string]]::new() - foreach ($source in $targets[$target]){ - $pref = $ErrorActionPreference - try { - $ErrorActionPreference = 'Break' - $file = Split-Path $Source -leaf - $repo = Get-Repo $source - $content = Get-Source-Content $file $repo - $lines = Wrap-Content -Source $source -Content $content -Info @{ source = $source; file = $file; repo = $repo } - foreach($ln in $lines){ - $targetLines.Add($ln) + foreach ($target in $targets.Keys){ + $path = "dist/$target" + $modulecontents = [System.Collections.Generic.List[string]]::new() + foreach ($source in $targets[$target]){ + $pref = $ErrorActionPreference + try { + $ErrorActionPreference = 'Break' + $file = Split-Path $Source -leaf + $repo = Get-Repo $source + $info = @{ source = $source; file = $file; repo = $repo } + $content = Get-Source-Content $file $repo + $lines = Wrap-Content -Source $source -Content $content -Info $info + $lines = $lines | % { "`t$_" } + $module = ($lines -join "`n").Trim(); + + # if (!$module.Contains('module.exports') { + # $module -match '\bfunction\s+([A-Z][a-zA-Z0-9_]+)\s*(\.*' | Out-Null + # } + + $modulecontents.Add($module) + } catch { + $modulecontents = $null + $ErrorActionPreference = $pref + Write-Error $_ + } finally { + $ErrorActionPreference = $pref } - } catch { - $targetLines = $null - $ErrorActionPreference = $pref - Write-Error $_ - } finally { - $ErrorActionPreference = $pref } - } - if ($targetLines -and $targetLines.Count -gt 0) { - if ([System.IO.File]::Exists($path)) { - Remove-Item $path + if ($modulecontents -and $modulecontents.Count -gt 0) { + + $wrappedmodules = ($modulecontents | % { + "(require, module)=>{`n$_`n}" + }) -join "`n,`n" + + $content = $wrapper.Replace($PLACEHOLDER, $wrappedmodules) + + if ([System.IO.File]::Exists($path)) { + Remove-Item $path + } + $content >> $path } - $content = ($targetLines -join "`n").Trim(); - $content >> $path } } + +Concat-Modules From 9309ea4d3983b87a2c63fd5d814d8769cb0ea230 Mon Sep 17 00:00:00 2001 From: Derek Ziemba Date: Thu, 9 May 2024 11:53:20 -0500 Subject: [PATCH 4/6] chore(build): build it & update dist/ --- dist/bililiteRange.js | 2073 +++++++++++++++++++-------------- dist/editor.js | 2546 ----------------------------------------- 2 files changed, 1239 insertions(+), 3380 deletions(-) delete mode 100644 dist/editor.js diff --git a/dist/bililiteRange.js b/dist/bililiteRange.js index 667b278..4901f7e 100644 --- a/dist/bililiteRange.js +++ b/dist/bililiteRange.js @@ -1,885 +1,1290 @@ -// bililiteRange.js 2023-03-05 - -// bililiteRange.js commit ef1c276 'use strict'; -let bililiteRange; // create one global variable +((target, fakeloader, esmodules) =>{ + const mod = fakeloader(esmodules); + const exported = mod.exports.default; + target[exported.name] = exported; -(function(){ - -const datakey = Symbol(); // use as the key to modify elements. +})( +(()=>{ + if (typeof module !== 'undefined' && typeof module === 'object' && module) { + if (!module.exports || typeof module.exports !== 'object') { + if (typeof exports !== 'undefined' && exports) { + module.exports = exports; + } else { + module.exports = {}; + } + } + return module.exports; -bililiteRange = function(el){ - var ret; - if (el.setSelectionRange){ - // Element is an input or textarea - // note that some input elements do not allow selections - try{ - el.selectionStart = el.selectionStart; - ret = new InputRange(); - }catch(e){ - ret = new NothingRange(); - } - }else{ - // Standards, with any other kind of element - ret = new W3CRange(); - } - ret._el = el; - // determine parent document, as implemented by John McLear - ret._doc = el.ownerDocument; - ret._win = ret._doc.defaultView; - ret._bounds = [0, ret.length]; - + } else if (typeof globalThis !== 'undefined' && globalThis) { + return globalThis; - if (!(el[datakey])){ // we haven't processed this element yet - const data = createDataObject (el); - startupHooks.forEach ( hook => hook (el, ret, data) ); - } - return ret; -} + } else if (typeof window !== 'undefined' && window) { + return window; + } +})(), +function fakeloader(esmodules){ + let exports = { + get default() { return exports; }, + set default(value) { + const current = Object.getOwnPropertyDescriptors(exports); + Object.defineProperties(value, current); -bililiteRange.version = 3.2; + if (typeof value === 'function') { + if (!value.name) { + throw new Error('default export must be named'); + } + if (typeof current === 'function') { + throw new Error('Only export default a single function, as the name of that one function will be what is exported'); + } + value[value.name] = value; + } + exports = value; + } + }; + const module = { + get exports() { return exports; }, + set exports(value) { exports = value; }, + }; -const startupHooks = new Set(); -bililiteRange.addStartupHook = fn => startupHooks.add(fn); -startupHooks.add (trackSelection); -startupHooks.add (fixInputEvents); -startupHooks.add (correctNewlines); + function require(name) { + return module.exports; + } -// selection tracking. We want clicks to set the selection to the clicked location but tabbing in or element.focus() should restore -// the selection to what it was. -// There's no good way to do this. I just assume that a mousedown (or a drag and drop -// into the element) within 100 ms of the focus event must have caused the focus, and -// therefore we should not restore the selection. -function trackSelection (element, range, data){ - data.selection = [0,0]; - range.listen('focusout', evt => data.selection = range._nativeSelection() ); - range.listen('mousedown', evt => data.mousetime = evt.timeStamp ); - range.listen('drop', evt => data.mousetime = evt.timeStamp ); - range.listen('focus', evt => { - if ('mousetime' in data && evt.timeStamp - data.mousetime < 100) return; - range._nativeSelect(range._nativeRange(data.selection)) - }); -} + for (let esmodule of esmodules) { + if (!esmodule) { continue; } + esmodule(require, module); + } -function fixInputEvents (element, range, data){ - // DOM 3 input events, https://www.w3.org/TR/input-events-1/ - // have a data field with the text inserted, but that isn't enough to fully describe the change; - // we need to know the old text (or at least its length) - // and *where* the new text was inserted. - // So we enhance input events with that information. - // the "newText" should always be the same as the 'data' field, if it is defined - data.oldText = range.all(); - data.liveRanges = new Set(); - range.listen('input', evt => { - const newText = range.all(); - if (!evt.bililiteRange){ - evt.bililiteRange = diff (data.oldText, newText); - if (evt.bililiteRange.unchanged){ - // no change. Assume that whatever happened, happened at the selection point (and use whatever data the browser gives us). - evt.bililiteRange.start = range.clone().bounds('selection')[1] - (evt.data || '').length; + return module; +}, +[ +// in the form of: +// (require, module) => { the code }, +(require, module)=>{ +/************************************************* + * source: bililiteRange.js + * file: bililiteRange.js + * repo: + * commit: bcf2d15 + * version: 5.0.0 + * date: 2024-05-09 + ************************************************/ + + module.exports.default = bililiteRange; + module.exports.bililiteRange = bililiteRange; + + + + const datakey = Symbol(); // use as the key to modify elements. + + /** + @template {HTMLInputElement} E + @param {E} el + @return {(typeof bililiteRange)['prototype'] & (E extends HTMLInputElement ? InputRange : E extends HTMLTextAreaElement ? W3CRange : NothingRange)} + */ + + function bililiteRange(el) { + /**@type {Range} */ + var ret; + if (el.setSelectionRange) { + // Element is an input or textarea + // note that some input elements do not allow selections + try { + el.selectionStart = el.selectionStart; + ret = new InputRange(); + } catch (e) { + ret = new NothingRange(); } + } else { + // Standards, with any other kind of element + ret = new W3CRange(); } - data.oldText = newText; - - // Also update live ranges on this element - data.liveRanges.forEach( rng => { - const start = evt.bililiteRange.start; - const oldend = start + evt.bililiteRange.oldText.length; - const newend = start + evt.bililiteRange.newText.length; - // adjust bounds; this tries to emulate the algorithm that Microsoft Word uses for bookmarks - let [b0, b1] = rng.bounds(); - if (b0 <= start){ - // no change - }else if (b0 > oldend){ - b0 += newend - oldend; - }else{ - b0 = newend; - } - if (b1 < start){ - // no change - }else if (b1 >= oldend){ - b1 += newend - oldend; - }else{ - b1 = start; - } - rng.bounds([b0, b1]); - }) - }); -} - -function diff (oldText, newText){ - // Try to find the changed text, assuming it was a continuous change - if (oldText == newText){ - return { - unchanged: true, - start: 0, - oldText, - newText + ret._el = el; + // determine parent document, as implemented by John McLear + ret._doc = el.ownerDocument; + ret._win = ret._doc.defaultView; + ret._bounds = [0, ret.length]; + + if (!el[datakey]) { + // we haven't processed this element yet + const data = createDataObject(el); + startupHooks.forEach((hook) => hook(el, ret, data)); } + return ret; } - - const oldlen = oldText.length; - const newlen = newText.length; - for (var i = 0; i < newlen && i < oldlen; ++i){ - if (newText.charAt(i) != oldText.charAt(i)) break; - } - const start = i; - for (i = 0; i < newlen && i < oldlen; ++i){ - let newpos = newlen-i-1, oldpos = oldlen-i-1; - if (newpos < start || oldpos < start) break; - if (newText.charAt(newpos) != oldText.charAt(oldpos)) break; + + bililiteRange.version = 5.0; + + const startupHooks = new Set(); + bililiteRange.addStartupHook = (fn) => startupHooks.add(fn); + startupHooks.add(trackSelection); + startupHooks.add(fixInputEvents); + startupHooks.add(correctNewlines); + + // selection tracking. We want clicks to set the selection to the clicked location but tabbing in or element.focus() should restore + // the selection to what it was. + // There's no good way to do this. I just assume that a mousedown (or a drag and drop + // into the element) within 100 ms of the focus event must have caused the focus, and + // therefore we should not restore the selection. + function trackSelection(element, range, data) { + data.selection = [0, 0]; + range.listen( + "focusout", + (evt) => (data.selection = range._nativeSelection()) + ); + range.listen("mousedown", (evt) => (data.mousetime = evt.timeStamp)); + range.listen("drop", (evt) => (data.mousetime = evt.timeStamp)); + range.listen("focus", (evt) => { + if ("mousetime" in data && evt.timeStamp - data.mousetime < 100) return; + range._nativeSelect(range._nativeRange(data.selection)); + }); } - const oldend = oldlen-i; - const newend = newlen-i; - return { - start, - oldText: oldText.slice(start, oldend), - newText: newText.slice(start, newend) + + function fixInputEvents(element, range, data) { + // DOM 3 input events, https://www.w3.org/TR/input-events-1/ + // have a data field with the text inserted, but that isn't enough to fully describe the change; + // we need to know the old text (or at least its length) + // and *where* the new text was inserted. + // So we enhance input events with that information. + // the "newText" should always be the same as the 'data' field, if it is defined + data.oldText = range.all(); + data.liveRanges = new Set(); + range.listen("input", (evt) => { + const newText = range.all(); + if (!evt.bililiteRange) { + evt.bililiteRange = diff(data.oldText, newText); + if (evt.bililiteRange.unchanged) { + // no change. Assume that whatever happened, happened at the selection point (and use whatever data the browser gives us). + evt.bililiteRange.start = + range.clone().bounds("selection")[1] - (evt.data || "").length; + } + } + data.oldText = newText; + + // Also update live ranges on this element + data.liveRanges.forEach((rng) => { + const start = evt.bililiteRange.start; + const oldend = start + evt.bililiteRange.oldText.length; + const newend = start + evt.bililiteRange.newText.length; + // adjust bounds; this tries to emulate the algorithm that Microsoft Word uses for bookmarks + let [b0, b1] = rng.bounds(); + if (b0 <= start) { + // no change + } else if (b0 > oldend) { + b0 += newend - oldend; + } else { + b0 = newend; + } + if (b1 < start) { + // no change + } else if (b1 >= oldend) { + b1 += newend - oldend; + } else { + b1 = start; + } + rng.bounds([b0, b1]); + }); + }); } -}; -bililiteRange.diff = diff; // expose - -function correctNewlines (element, range, data){ - // we need to insert newlines rather than create new elements, so character-based calculations work - range.listen('paste', evt => { - if (evt.defaultPrevented) return; - // windows adds \r's to clipboard! - range.clone().bounds('selection'). - text(evt.clipboardData.getData("text/plain").replace(/\r/g,''), {inputType: 'insertFromPaste'}). - bounds('endbounds'). - select(). - scrollIntoView(); - evt.preventDefault(); - }); - range.listen('keydown', function(evt){ - if (evt.ctrlKey || evt.altKey || evt.shiftKey || evt.metaKey) return; - if (evt.defaultPrevented) return; - if (evt.key == 'Enter'){ - range.clone().bounds('selection'). - text('\n', {inputType: 'insertLineBreak'}). - bounds('endbounds'). - select(). - scrollIntoView(); - evt.preventDefault(); - } - }); -} - -// convenience function for defining input events -function inputEventInit(type, oldText, newText, start, inputType){ - return { - type, - inputType, - data: newText, - bubbles: true, - bililiteRange: { - unchanged: (oldText == newText), - start, - oldText, - newText - } - }; -} - -// base class -function Range(){} -Range.prototype = { - // allow use of range[0] and range[1] for start and end of bounds - get 0(){ - return this.bounds()[0]; - }, - set 0(x){ - this.bounds([x, this[1]]); - return x; - }, - get 1(){ - return this.bounds()[1]; - }, - set 1(x){ - this.bounds([this[0], x]); - return x; - }, - all: function(text){ - if (arguments.length){ - return this.bounds('all').text(text, {inputType: 'insertReplacementText'}); - }else{ - return this._el[this._textProp]; - } - }, - bounds: function(s){ - if (typeof s === 'number'){ - this._bounds = [s,s]; - }else if (bililiteRange.bounds[s]){ - this.bounds(bililiteRange.bounds[s].apply(this, arguments)); - }else if (s && s.bounds){ - this._bounds = s.bounds(); // copy bounds from an existing range - }else if (s){ - this._bounds = s; // don't do error checking now; things may change at a moment's notice - }else{ - // constrain bounds now - var b = [ - Math.max(0, Math.min (this.length, this._bounds[0])), - Math.max(0, Math.min (this.length, this._bounds[1])) - ]; - b[1] = Math.max(b[0], b[1]); - return b; - } - return this; // allow for chaining - }, - clone: function(){ - return bililiteRange(this._el).bounds(this.bounds()); - }, - get data(){ - return this._el[datakey]; - }, - dispatch: function(opts = {}){ - var event = new Event (opts.type, opts); - event.view = this._win; - for (let prop in opts) try { event[prop] = opts[prop] } catch(e){}; // ignore read-only errors for properties that were copied in the previous line - this._el.dispatchEvent(event); // note that the event handlers will be called synchronously, before the "return this;" - return this; - }, - get document() { - return this._doc; - }, - dontlisten: function (type, func = console.log, target){ - target ??= this._el; - target.removeEventListener(type, func); - return this; - }, - get element() { - return this._el - }, - get length() { - return this._el[this._textProp].length; - }, - live (on = true){ - this.data.liveRanges[on ? 'add' : 'delete'](this); - return this; - }, - listen: function (type, func = console.log, target){ - target ??= this._el; - target.addEventListener(type, func); - return this; - }, - scrollIntoView() { - var top = this.top(); - // note that for TEXTAREA's, this.top() will do the scrolling and the following is irrelevant. - // scroll into position if necessary - if (this._el.scrollTop > top || this._el.scrollTop+this._el.clientHeight < top){ - this._el.scrollTop = top; - } - return this; - }, - select: function(){ - var b = this.data.selection = this.bounds(); - if (this._el === this._doc.activeElement){ - // only actually select if this element is active! - this._nativeSelect(this._nativeRange(b)); - } - this.dispatch({type: 'select', bubbles: true}); - return this; // allow for chaining - }, - selection: function(text){ - if (arguments.length){ - return this.bounds('selection').text(text).bounds('endbounds').select(); - }else{ - return this.bounds('selection').text(); + + function diff(oldText, newText) { + // Try to find the changed text, assuming it was a continuous change + if (oldText == newText) { + return { + unchanged: true, + start: 0, + oldText, + newText + }; } - }, - sendkeys: function (text){ - this.data.sendkeysOriginalText = this.text(); - this.data.sendkeysBounds = undefined; - function simplechar (rng, c){ - if (/^{[^}]*}$/.test(c)) c = c.slice(1,-1); // deal with unknown {key}s - rng.text(c).bounds('endbounds'); + + const oldlen = oldText.length; + const newlen = newText.length; + for (var i = 0; i < newlen && i < oldlen; ++i) { + if (newText.charAt(i) != oldText.charAt(i)) break; } - text.replace(/{[^}]*}|[^{]+|{/g, part => (bililiteRange.sendkeys[part] || simplechar)(this, part, simplechar) ); - this.bounds(this.data.sendkeysBounds); - this.dispatch({type: 'sendkeys', detail: text}); - return this; - }, - text: function(text, {inputType = 'insertText'} = {}){ - if ( text !== undefined ){ - let eventparams = [this.text(), text, this[0], inputType]; - this.dispatch (inputEventInit('beforeinput',...eventparams)); - this._nativeSetText(text, this._nativeRange(this.bounds())); - this[1] = this[0]+text.length; - this.dispatch (inputEventInit('input',...eventparams)); - return this; // allow for chaining - }else{ - return this._nativeGetText(this._nativeRange(this.bounds())); + const start = i; + for (i = 0; i < newlen && i < oldlen; ++i) { + let newpos = newlen - i - 1, + oldpos = oldlen - i - 1; + if (newpos < start || oldpos < start) break; + if (newText.charAt(newpos) != oldText.charAt(oldpos)) break; } - }, - top: function(){ - return this._nativeTop(this._nativeRange(this.bounds())); - }, - get window() { - return this._win; - }, - wrap: function (n){ - this._nativeWrap(n, this._nativeRange(this.bounds())); - return this; - }, -}; - -// allow extensions ala jQuery -bililiteRange.prototype = Range.prototype; -bililiteRange.extend = function(fns){ - Object.assign(bililiteRange.prototype, fns); -}; - -bililiteRange.override = (name, fn) => { - const oldfn = bililiteRange.prototype[name]; - bililiteRange.prototype[name] = function(){ - const oldsuper = this.super; - this.super = oldfn; - const ret = fn.apply(this, arguments); - this.super = oldsuper; - return ret; - }; -} - -//bounds functions -bililiteRange.bounds = { - all: function() { return [0, this.length] }, - start: function() { return 0 }, - end: function() { return this.length }, - selection: function() { - if (this._el === this._doc.activeElement){ - this.bounds ('all'); // first select the whole thing for constraining - return this._nativeSelection(); - }else{ - return this.data.selection; - } - }, - startbounds: function() { return this[0] }, - endbounds: function() { return this[1] }, - union: function (name,...rest) { - const b = this.clone().bounds(...rest); - return [ Math.min(this[0], b[0]), Math.max(this[1], b[1]) ]; - }, - intersection: function (name,...rest) { - const b = this.clone().bounds(...rest); - return [ Math.max(this[0], b[0]), Math.min(this[1], b[1]) ]; + const oldend = oldlen - i; + const newend = newlen - i; + return { + start, + oldText: oldText.slice(start, oldend), + newText: newText.slice(start, newend) + }; } -}; - -// sendkeys functions -bililiteRange.sendkeys = { - '{tab}': function (rng, c, simplechar){ - simplechar(rng, '\t'); // useful for inserting what would be whitespace - }, - '{newline}': function (rng){ - rng.text('\n', {inputType: 'insertLineBreak'}).bounds('endbounds'); - }, - '{backspace}': function (rng){ - var b = rng.bounds(); - if (b[0] == b[1]) rng.bounds([b[0]-1, b[0]]); // no characters selected; it's just an insertion point. Remove the previous character - rng.text('', {inputType: 'deleteContentBackward'}); // delete the characters and update the selection - }, - '{del}': function (rng){ - var b = rng.bounds(); - if (b[0] == b[1]) rng.bounds([b[0], b[0]+1]); // no characters selected; it's just an insertion point. Remove the next character - rng.text('', {inputType: 'deleteContentForward'}).bounds('endbounds'); // delete the characters and update the selection - }, - '{rightarrow}': function (rng){ - var b = rng.bounds(); - if (b[0] == b[1]) ++b[1]; // no characters selected; it's just an insertion point. Move to the right - rng.bounds([b[1], b[1]]); - }, - '{leftarrow}': function (rng){ - var b = rng.bounds(); - if (b[0] == b[1]) --b[0]; // no characters selected; it's just an insertion point. Move to the left - rng.bounds([b[0], b[0]]); - }, - '{selectall}': function (rng){ - rng.bounds('all'); - }, - '{selection}': function (rng){ - // insert the characters without the sendkeys processing - rng.text(rng.data.sendkeysOriginalText).bounds('endbounds'); - }, - '{mark}': function (rng){ - rng.data.sendkeysBounds = rng.bounds(); - }, - '{ctrl-Home}': (rng, c, simplechar) => rng.bounds('start'), - '{ctrl-End}': (rng, c, simplechar) => rng.bounds('end') -}; -// Synonyms from the DOM standard (http://www.w3.org/TR/DOM-Level-3-Events-key/) -bililiteRange.sendkeys['{Enter}'] = bililiteRange.sendkeys['{enter}'] = bililiteRange.sendkeys['{newline}']; -bililiteRange.sendkeys['{Backspace}'] = bililiteRange.sendkeys['{backspace}']; -bililiteRange.sendkeys['{Delete}'] = bililiteRange.sendkeys['{del}']; -bililiteRange.sendkeys['{ArrowRight}'] = bililiteRange.sendkeys['{rightarrow}']; -bililiteRange.sendkeys['{ArrowLeft}'] = bililiteRange.sendkeys['{leftarrow}']; - -// an input element in a standards document. "Native Range" is just the bounds array -function InputRange(){} -InputRange.prototype = new Range(); -InputRange.prototype._textProp = 'value'; -InputRange.prototype._nativeRange = function(bounds) { - return bounds || [0, this.length]; -}; -InputRange.prototype._nativeSelect = function (rng){ - this._el.setSelectionRange(rng[0], rng[1]); -}; -InputRange.prototype._nativeSelection = function(){ - return [this._el.selectionStart, this._el.selectionEnd]; -}; -InputRange.prototype._nativeGetText = function(rng){ - return this._el.value.substring(rng[0], rng[1]); -}; -InputRange.prototype._nativeSetText = function(text, rng){ - var val = this._el.value; - this._el.value = val.substring(0, rng[0]) + text + val.substring(rng[1]); -}; -InputRange.prototype._nativeEOL = function(){ - this.text('\n'); -}; -InputRange.prototype._nativeTop = function(rng){ - if (rng[0] == 0) return 0; // the range starts at the top - const el = this._el; - if (el.nodeName == 'INPUT') return 0; - const text = el.value; - const selection = [el.selectionStart, el.selectionEnd]; - // hack from https://code.google.com/archive/p/proveit-js/source/default/source, highlightLengthAtIndex function - // note that this results in the element being scrolled; the actual number returned is irrelevant - el.value = text.slice(0, rng[0]); - el.scrollTop = Number.MAX_SAFE_INTEGER; - el.value = text; - el.setSelectionRange(...selection); - return el.scrollTop; -} -InputRange.prototype._nativeWrap = function() {throw new Error("Cannot wrap in a text element")}; - -function W3CRange(){} -W3CRange.prototype = new Range(); -W3CRange.prototype._textProp = 'textContent'; -W3CRange.prototype._nativeRange = function (bounds){ - var rng = this._doc.createRange(); - rng.selectNodeContents(this._el); - if (bounds){ - w3cmoveBoundary (rng, bounds[0], true, this._el); - rng.collapse (true); - w3cmoveBoundary (rng, bounds[1]-bounds[0], false, this._el); + bililiteRange.diff = diff; // expose + + function correctNewlines(element, range, data) { + // we need to insert newlines rather than create new elements, so character-based calculations work + range.listen("paste", (evt) => { + if (evt.defaultPrevented) return; + // windows adds \r's to clipboard! + range + .clone() + .bounds("selection") + .text(evt.clipboardData.getData("text/plain").replace(/\r/g, ""), { + inputType: "insertFromPaste" + }) + .bounds("endbounds") + .select() + .scrollIntoView(); + evt.preventDefault(); + }); + range.listen("keydown", function (evt) { + if (evt.ctrlKey || evt.altKey || evt.shiftKey || evt.metaKey) return; + if (evt.defaultPrevented) return; + if (evt.key == "Enter") { + range + .clone() + .bounds("selection") + .text("\n", { inputType: "insertLineBreak" }) + .bounds("endbounds") + .select() + .scrollIntoView(); + evt.preventDefault(); + } + }); + } + + // convenience function for defining input events + function inputEventInit(type, oldText, newText, start, inputType) { + return { + type, + inputType, + data: newText, + bubbles: true, + bililiteRange: { + unchanged: oldText == newText, + start, + oldText, + newText + } + }; } - return rng; -}; -W3CRange.prototype._nativeSelect = function (rng){ - this._win.getSelection().removeAllRanges(); - this._win.getSelection().addRange (rng); -}; -W3CRange.prototype._nativeSelection = function (){ - // returns [start, end] for the selection constrained to be in element - var rng = this._nativeRange(); // range of the element to constrain to - if (this._win.getSelection().rangeCount == 0) return [this.length, this.length]; // append to the end - var sel = this._win.getSelection().getRangeAt(0); - return [ - w3cstart(sel, rng), - w3cend (sel, rng) - ]; -}; -W3CRange.prototype._nativeGetText = function (rng){ - return rng.toString(); -}; -W3CRange.prototype._nativeSetText = function (text, rng){ - rng.deleteContents(); - rng.insertNode (this._doc.createTextNode(text)); - // Lea Verou's "super dirty fix" to #31 - if(text == '\n' && this[1]+1 == this._el.textContent.length) { - // inserting a newline at the end - this._el.innerHTML = this._el.innerHTML + '\n'; + + // base class + /** + @class + @abstract + @mixes W3CRange + @mixes InputRange + @mixes NothingRange + @template {HTMLElement} E + @param {E} el + */ + class Range { + constructor(el) { + /**@type {E} */ + this._el = this._el||el; + // determine parent document, as implemented by John McLear + /**@type {Document} */ + this._doc = undefined; + /**@type {Window} */ + this._win = undefined; + /**@type {ArrayLike} */ + this._bounds = undefined; + + /**@type {keyof { [K in keyof E as E[K] extends string ? K : never]: E[K] }} */ + this._textProp = undefined; + } + // allow use of range[0] and range[1] for start and end of bounds + get 0() { + return this.bounds()[0]; + } + set 0(x) { + this.bounds([x, this[1]]); + return x; + } + get 1() { + return this.bounds()[1]; + } + set 1(x) { + this.bounds([this[0], x]); + return x; + } + all(text) { + if (arguments.length) { + return this.bounds("all").text(text, { + inputType: "insertReplacementText" + }); + } else { + return this._el[this._textProp]; + } + } + bounds(/**@type {number|[number, number]|Range|string|undefined} */ s) { + if (typeof s === "number") { + this._bounds = [s, s]; + } else if (bililiteRange.bounds[s]) { + this.bounds(bililiteRange.bounds[s].apply(this, arguments)); + } else if (s && s.bounds) { + this._bounds = s.bounds(); // copy bounds from an existing range + } else if (s) { + this._bounds = s; // don't do error checking now; things may change at a moment's notice + } else { + // constrain bounds now + var b = [ + Math.max(0, Math.min(this.length, this._bounds[0])), + Math.max(0, Math.min(this.length, this._bounds[1])) + ]; + b[1] = Math.max(b[0], b[1]); + return b; + } + return this; // allow for chaining + } + clone() { + return bililiteRange(this._el).bounds(this.bounds()); + } + get data() { + return this._el[datakey]; + } + dispatch(opts = {}) { + var event = new Event(opts.type, opts); + event.view = this._win; + for (let prop in opts) + try { + event[prop] = opts[prop]; + } catch (e) { } // ignore read-only errors for properties that were copied in the previous line + this._el.dispatchEvent(event); // note that the event handlers will be called synchronously, before the "return this;" + return this; + } + get document() { + return this._doc; + } + dontlisten(type, func = console.log, target) { + target ??= this._el; + target.removeEventListener(type, func); + return this; + } + get element() { + return this._el; + } + get length() { + return this._el[this._textProp].length; + } + live(on = true) { + this.data.liveRanges[on ? "add" : "delete"](this); + return this; + } + listen(type, func = console.log, target) { + target ??= this._el; + target.addEventListener(type, func); + return this; + } + scrollIntoView() { + var top = this.top(); + // note that for TEXTAREA's, this.top() will do the scrolling and the following is irrelevant. + // scroll into position if necessary + if (this._el.scrollTop > top || + this._el.scrollTop + this._el.clientHeight < top) { + this._el.scrollTop = top; + } + return this; + } + select() { + var b = (this.data.selection = this.bounds()); + if (this._el === this._doc.activeElement) { + // only actually select if this element is active! + this._nativeSelect(this._nativeRange(b)); + } + this.dispatch({ type: "select", bubbles: true }); + return this; // allow for chaining + } + selection(text) { + if (arguments.length) { + return this.bounds("selection").text(text).bounds("endbounds").select(); + } else { + return this.bounds("selection").text(); + } + } + sendkeys(text) { + this.data.sendkeysOriginalText = this.text(); + this.data.sendkeysBounds = undefined; + function simplechar(rng, c) { + if (/^{[^}]*}$/.test(c)) c = c.slice(1, -1); // deal with unknown {key}s + rng.text(c).bounds("endbounds"); + } + text.replace(/{[^}]*}|[^{]+|{/g, (part) => (bililiteRange.sendkeys[part] || simplechar)(this, part, simplechar) + ); + this.bounds(this.data.sendkeysBounds); + this.dispatch({ type: "sendkeys", detail: text }); + return this; + } + text(text, { inputType = "insertText" } = {}) { + if (text !== undefined) { + let eventparams = [this.text(), text, this[0], inputType]; + this.dispatch(inputEventInit("beforeinput", ...eventparams)); + this._nativeSetText(text, this._nativeRange(this.bounds())); + this[1] = this[0] + text.length; + this.dispatch(inputEventInit("input", ...eventparams)); + return this; // allow for chaining + } else { + return this._nativeGetText(this._nativeRange(this.bounds())); + } + } + top() { + return this._nativeTop(this._nativeRange(this.bounds())); + } + get window() { + return this._win; + } + wrap(n) { + this._nativeWrap(n, this._nativeRange(this.bounds())); + return this; + } } - this._el.normalize(); // merge the text with the surrounding text + + // allow extensions ala jQuery + bililiteRange.prototype = Range.prototype; + bililiteRange.extend = function extend(fns) { + Object.assign(bililiteRange.prototype, fns); + }; + + bililiteRange.override = (name, fn) => { + const oldfn = bililiteRange.prototype[name]; + bililiteRange.prototype[name] = function () { + const oldsuper = this.super; + this.super = oldfn; + const ret = fn.apply(this, arguments); + this.super = oldsuper; + return ret; + }; + }; + + //bounds functions + bililiteRange.bounds = { + all() { + return [0, this.length]; + }, + start() { + return 0; + }, + end() { + return this.length; + }, + selection() { + if (this._el === this._doc.activeElement) { + this.bounds("all"); // first select the whole thing for constraining + return this._nativeSelection(); + } else { + return this.data.selection; + } + }, + startbounds() { + return this[0]; + }, + endbounds() { + return this[1]; + }, + union(name, ...rest) { + const b = this.clone().bounds(...rest); + return [Math.min(this[0], b[0]), Math.max(this[1], b[1])]; + }, + intersection(name, ...rest) { + const b = this.clone().bounds(...rest); + return [Math.max(this[0], b[0]), Math.min(this[1], b[1])]; + } }; -W3CRange.prototype._nativeEOL = function(){ - var rng = this._nativeRange(this.bounds()); - rng.deleteContents(); - var br = this._doc.createElement('br'); - br.setAttribute ('_moz_dirty', ''); // for Firefox - rng.insertNode (br); - rng.insertNode (this._doc.createTextNode('\n')); - rng.collapse (false); -}; -W3CRange.prototype._nativeTop = function(rng){ - if (this.length == 0) return 0; // no text, no scrolling - if (rng.toString() == ''){ - var textnode = this._doc.createTextNode('X'); - rng.insertNode (textnode); + + // sendkeys functions + bililiteRange.sendkeys = { + "{tab}"(rng, c, simplechar) { + simplechar(rng, "\t"); // useful for inserting what would be whitespace + }, + "{newline}"(rng) { + rng.text("\n", { inputType: "insertLineBreak" }).bounds("endbounds"); + }, + "{backspace}"(rng) { + const b = rng.bounds(); + if (b[0] == b[1]) rng.bounds([b[0] - 1, b[0]]); // no characters selected; it's just an insertion point. Remove the previous character + rng.text("", { inputType: "deleteContentBackward" }); // delete the characters and update the selection + }, + "{del}"(rng) { + const b = rng.bounds(); + if (b[0] == b[1]) rng.bounds([b[0], b[0] + 1]); // no characters selected; it's just an insertion point. Remove the next character + rng.text("", { inputType: "deleteContentForward" }).bounds("endbounds"); // delete the characters and update the selection + }, + "{rightarrow}"(rng) { + const b = rng.bounds(); + if (b[0] == b[1]) ++b[1]; // no characters selected; it's just an insertion point. Move to the right + rng.bounds([b[1], b[1]]); + }, + "{leftarrow}"(rng) { + const b = rng.bounds(); + if (b[0] == b[1]) --b[0]; // no characters selected; it's just an insertion point. Move to the left + rng.bounds([b[0], b[0]]); + }, + "{selectall}"(rng) { + rng.bounds("all"); + }, + "{selection}"(rng) { + // insert the characters without the sendkeys processing + rng.text(rng.data.sendkeysOriginalText).bounds("endbounds"); + }, + "{mark}"(rng) { + rng.data.sendkeysBounds = rng.bounds(); + }, + "{ctrl-Home}": (rng, c, simplechar) => rng.bounds("start"), + "{ctrl-End}": (rng, c, simplechar) => rng.bounds("end") + }; + // Synonyms from the DOM standard (http://www.w3.org/TR/DOM-Level-3-Events-key/) + bililiteRange.sendkeys["{Enter}"] = bililiteRange.sendkeys["{enter}"] = + bililiteRange.sendkeys["{newline}"]; + bililiteRange.sendkeys["{Backspace}"] = bililiteRange.sendkeys["{backspace}"]; + bililiteRange.sendkeys["{Delete}"] = bililiteRange.sendkeys["{del}"]; + bililiteRange.sendkeys["{ArrowRight}"] = bililiteRange.sendkeys["{rightarrow}"]; + bililiteRange.sendkeys["{ArrowLeft}"] = bililiteRange.sendkeys["{leftarrow}"]; + + /** + an input element in a standards document. "Native Range" is just the bounds array + @class + @template {HTMLInputElement} E + @extends Range + */ + class InputRange extends Range { + /**@param {E|undefined} el */ + constructor(el) { + super(el); + this._textProp = "value"; + } + + _nativeRange(bounds) { + return bounds || [0, this.length]; + } + + _nativeSelect(rng) { + this._el.setSelectionRange(rng[0], rng[1]); + } + + _nativeSelection() { + return [this._el.selectionStart, this._el.selectionEnd]; + } + + _nativeGetText(rng) { + return this._el.value.substring(rng[0], rng[1]); + } + + _nativeSetText(text, rng) { + const val = this._el.value; + this._el.value = val.substring(0, rng[0]) + text + val.substring(rng[1]); + } + + _nativeEOL() { + this.text("\n"); + } + + _nativeTop(rng) { + if (rng[0] == 0) return 0; // the range starts at the top + const el = this._el; + if (el.nodeName == "INPUT") return 0; + const text = el.value; + const selection = [el.selectionStart, el.selectionEnd]; + // hack from https://code.google.com/archive/p/proveit-js/source/default/source, highlightLengthAtIndex function + // note that this results in the element being scrolled; the actual number returned is irrelevant + el.value = text.slice(0, rng[0]); + el.scrollTop = Number.MAX_SAFE_INTEGER; + el.value = text; + el.setSelectionRange(...selection); + return el.scrollTop; + } + + _nativeWrap() { + throw new Error("Cannot wrap in a text element"); + } } - var startrng = this._nativeRange([0,1]); - var top = rng.getBoundingClientRect().top - startrng.getBoundingClientRect().top; - if (textnode) textnode.parentNode.removeChild(textnode); - return top; -} -W3CRange.prototype._nativeWrap = function(n, rng) { - rng.surroundContents(n); -}; - -// W3C internals -function nextnode (node, root){ - // in-order traversal - // we've already visited node, so get kids then siblings - if (node.firstChild) return node.firstChild; - if (node.nextSibling) return node.nextSibling; - if (node===root) return null; - while (node.parentNode){ - // get uncles - node = node.parentNode; - if (node == root) return null; - if (node.nextSibling) return node.nextSibling; + + /** + @class + @template {HTMLTextAreaElement} E + @extends Range + */ + class W3CRange extends Range { + /**@param {E|undefined} el */ + constructor(el) { + super(el); + this._textProp = "textContent"; + } + + _nativeRange(bounds) { + const rng = this._doc.createRange(); + rng.selectNodeContents(this._el); + if (bounds) { + w3cmoveBoundary(rng, bounds[0], true, this._el); + rng.collapse(true); + w3cmoveBoundary(rng, bounds[1] - bounds[0], false, this._el); + } + return rng; + } + + _nativeSelect(rng) { + this._win.getSelection().removeAllRanges(); + this._win.getSelection().addRange(rng); + } + + _nativeSelection() { + const rng = this._nativeRange(); + if (this._win.getSelection().rangeCount == 0) + return [this.length, this.length]; + const sel = this._win.getSelection().getRangeAt(0); + return [w3cstart(sel, rng), w3cend(sel, rng)]; + } + + _nativeGetText(rng) { + return rng.toString(); + } + + _nativeSetText(text, rng) { + rng.deleteContents(); + rng.insertNode(this._doc.createTextNode(text)); + if (text == "\n" && this[1] + 1 == this._el.textContent.length) { + this._el.innerHTML = this._el.innerHTML + "\n"; + } + this._el.normalize(); + } + + _nativeEOL() { + const rng = this._nativeRange(this.bounds()); + rng.deleteContents(); + const br = this._doc.createElement("br"); + br.setAttribute("_moz_dirty", ""); + rng.insertNode(br); + rng.insertNode(this._doc.createTextNode("\n")); + rng.collapse(false); + } + + _nativeTop(rng) { + if (this.length == 0) return 0; + if (rng.toString() == "") { + var textnode = this._doc.createTextNode("X"); + rng.insertNode(textnode); + } + const startrng = this._nativeRange([0, 1]); + const top = + rng.getBoundingClientRect().top - startrng.getBoundingClientRect().top; + if (textnode) textnode.parentNode.removeChild(textnode); + return top; + } + + _nativeWrap(n, rng) { + rng.surroundContents(n); + } } - return null; -} -function w3cmoveBoundary (rng, n, bStart, el){ - // move the boundary (bStart == true ? start : end) n characters forward, up to the end of element el. Forward only! - // if the start is moved after the end, then an exception is raised - if (n <= 0) return; - var node = rng[bStart ? 'startContainer' : 'endContainer']; - if (node.nodeType == 3){ - // we may be starting somewhere into the text - n += rng[bStart ? 'startOffset' : 'endOffset']; + + // W3C internals + function nextnode(node, root) { + // in-order traversal + // we've already visited node, so get kids then siblings + if (node.firstChild) return node.firstChild; + if (node.nextSibling) return node.nextSibling; + if (node === root) return null; + while (node.parentNode) { + // get uncles + node = node.parentNode; + if (node == root) return null; + if (node.nextSibling) return node.nextSibling; + } + return null; } - while (node){ - if (node.nodeType == 3){ - var length = node.nodeValue.length; - if (n <= length){ - rng[bStart ? 'setStart' : 'setEnd'](node, n); - // special case: if we end next to a
, include that node. - if (n == length){ - // skip past zero-length text nodes - for (var next = nextnode (node, el); next && next.nodeType==3 && next.nodeValue.length == 0; next = nextnode(next, el)){ - rng[bStart ? 'setStartAfter' : 'setEndAfter'](next); + function w3cmoveBoundary(rng, n, bStart, el) { + // move the boundary (bStart == true ? start : end) n characters forward, up to the end of element el. Forward only! + // if the start is moved after the end, then an exception is raised + if (n <= 0) return; + var node = rng[bStart ? "startContainer" : "endContainer"]; + if (node.nodeType == 3) { + // we may be starting somewhere into the text + n += rng[bStart ? "startOffset" : "endOffset"]; + } + while (node) { + if (node.nodeType == 3) { + const length = node.nodeValue.length; + if (n <= length) { + rng[bStart ? "setStart" : "setEnd"](node, n); + // special case: if we end next to a
, include that node. + if (n == length) { + let next; + // skip past zero-length text nodes + for ( + next = nextnode(node, el); + next && next.nodeType == 3 && next.nodeValue.length == 0; + next = nextnode(next, el) + ) { + rng[bStart ? "setStartAfter" : "setEndAfter"](next); + } + if (next && next.nodeType == 1 && next.nodeName == "BR") + rng[bStart ? "setStartAfter" : "setEndAfter"](next); } - if (next && next.nodeType == 1 && next.nodeName == "BR") rng[bStart ? 'setStartAfter' : 'setEndAfter'](next); + return; + } else { + rng[bStart ? "setStartAfter" : "setEndAfter"](node); // skip past this one + n -= length; // and eat these characters } - return; - }else{ - rng[bStart ? 'setStartAfter' : 'setEndAfter'](node); // skip past this one - n -= length; // and eat these characters } + node = nextnode(node, el); } - node = nextnode (node, el); } -} -var START_TO_START = 0; // from the w3c definitions -var START_TO_END = 1; -var END_TO_END = 2; -var END_TO_START = 3; -// from the Mozilla documentation, for range.compareBoundaryPoints(how, sourceRange) -// -1, 0, or 1, indicating whether the corresponding boundary-point of range is respectively before, equal to, or after the corresponding boundary-point of sourceRange. - // * Range.END_TO_END compares the end boundary-point of sourceRange to the end boundary-point of range. - // * Range.END_TO_START compares the end boundary-point of sourceRange to the start boundary-point of range. - // * Range.START_TO_END compares the start boundary-point of sourceRange to the end boundary-point of range. - // * Range.START_TO_START compares the start boundary-point of sourceRange to the start boundary-point of range. -function w3cstart(rng, constraint){ - if (rng.compareBoundaryPoints (START_TO_START, constraint) <= 0) return 0; // at or before the beginning - if (rng.compareBoundaryPoints (END_TO_START, constraint) >= 0) return constraint.toString().length; - rng = rng.cloneRange(); // don't change the original - rng.setEnd (constraint.endContainer, constraint.endOffset); // they now end at the same place - return constraint.toString().length - rng.toString().length; -} -function w3cend (rng, constraint){ - if (rng.compareBoundaryPoints (END_TO_END, constraint) >= 0) return constraint.toString().length; // at or after the end - if (rng.compareBoundaryPoints (START_TO_END, constraint) <= 0) return 0; - rng = rng.cloneRange(); // don't change the original - rng.setStart (constraint.startContainer, constraint.startOffset); // they now start at the same place - return rng.toString().length; -} - -function NothingRange(){} -NothingRange.prototype = new Range(); -NothingRange.prototype._textProp = 'value'; -NothingRange.prototype._nativeRange = function(bounds) { - return bounds || [0,this.length]; -}; -NothingRange.prototype._nativeSelect = function (rng){ // do nothing -}; -NothingRange.prototype._nativeSelection = function(){ - return [0,0]; -}; -NothingRange.prototype._nativeGetText = function (rng){ - return this._el[this._textProp].substring(rng[0], rng[1]); -}; -NothingRange.prototype._nativeSetText = function (text, rng){ - var val = this._el[this._textProp]; - this._el[this._textProp] = val.substring(0, rng[0]) + text + val.substring(rng[1]); -}; -NothingRange.prototype._nativeEOL = function(){ - this.text('\n'); -}; -NothingRange.prototype._nativeTop = function(){ - return 0; -}; -NothingRange.prototype._nativeWrap = function() {throw new Error("Wrapping not implemented")}; - - -// data for elements, similar to jQuery data, but allows for monitoring with custom events -const monitored = new Set(); - -function signalMonitor(prop, value, element){ - const attr = `data-${prop}`; - element.dispatchEvent(new CustomEvent(attr, {bubbles: true, detail: value})); - try{ - element.setAttribute (attr, value); // illegal attribute names will throw. Ignore it - } finally { /* ignore */ } -} - -function createDataObject (el){ - return el[datakey] = new Proxy(new Data(el), { - set(obj, prop, value) { - obj[prop] = value; - if (monitored.has(prop)) signalMonitor(prop, value, obj.sourceElement); - return true; // in strict mode, 'set' returns a success flag - } - }); -} - -var Data = function(el) { - Object.defineProperty(this, 'sourceElement', { - value: el - }); -} - -Data.prototype = {}; -// for use with ex options. JSON.stringify(range.data) should return only the options that were -// both defined with bililiteRange.option() *and* actually had a value set on this particular data object. -// JSON.stringify (range.data.all) should return all the options that were defined. -Object.defineProperty(Data.prototype, 'toJSON', { - value: function(){ - let ret = {}; - for (let key in Data.prototype) if (this.hasOwnProperty(key)) ret[key] = this[key]; - return ret; + const START_TO_START = 0; // from the w3c definitions + const START_TO_END = 1; + const END_TO_END = 2; + const END_TO_START = 3; + // from the Mozilla documentation, for range.compareBoundaryPoints(how, sourceRange) + // -1, 0, or 1, indicating whether the corresponding boundary-point of range is respectively before, equal to, or after the corresponding boundary-point of sourceRange. + // * Range.END_TO_END compares the end boundary-point of sourceRange to the end boundary-point of range. + // * Range.END_TO_START compares the end boundary-point of sourceRange to the start boundary-point of range. + // * Range.START_TO_END compares the start boundary-point of sourceRange to the end boundary-point of range. + // * Range.START_TO_START compares the start boundary-point of sourceRange to the start boundary-point of range. + function w3cstart(rng, constraint) { + if (rng.compareBoundaryPoints(START_TO_START, constraint) <= 0) return 0; // at or before the beginning + if (rng.compareBoundaryPoints(END_TO_START, constraint) >= 0) + return constraint.toString().length; + rng = rng.cloneRange(); // don't change the original + rng.setEnd(constraint.endContainer, constraint.endOffset); // they now end at the same place + return constraint.toString().length - rng.toString().length; } -}); -Object.defineProperty(Data.prototype, 'all', { - get: function(){ - let ret = {}; - for (let key in Data.prototype) ret[key] = this[key]; - return ret; + function w3cend(rng, constraint) { + if (rng.compareBoundaryPoints(END_TO_END, constraint) >= 0) + return constraint.toString().length; // at or after the end + if (rng.compareBoundaryPoints(START_TO_END, constraint) <= 0) return 0; + rng = rng.cloneRange(); // don't change the original + rng.setStart(constraint.startContainer, constraint.startOffset); // they now start at the same place + return rng.toString().length; } -}); -Object.defineProperty(Data.prototype, 'trigger', { - value: function(){ - monitored.forEach(prop => signalMonitor (prop, this[prop], this.sourceElement)); + + /** + @class + @extends Range + */ + class NothingRange extends Range { + constructor() { + super(); + this._textProp = "value"; + } + + _nativeRange(bounds) { + return bounds || [0, this.length]; + } + + _nativeSelect(rng) { + // do nothing + } + + _nativeSelection() { + return [0, 0]; + } + + _nativeGetText(rng) { + return this._el[this._textProp].substring(rng[0], rng[1]); + } + + _nativeSetText(text, rng) { + var val = this._el[this._textProp]; + this._el[this._textProp] = + val.substring(0, rng[0]) + text + val.substring(rng[1]); + } + + _nativeEOL() { + this.text("\n"); + } + + _nativeTop() { + return 0; + } + + _nativeWrap() { + throw new Error("Wrapping not implemented"); + } } -}); - -bililiteRange.createOption = function (name, desc = {}){ - desc = Object.assign({ - enumerable: true, // use these as the defaults - writable: true, - configurable: true - }, Object.getOwnPropertyDescriptor(Data.prototype, name), desc); - if ('monitored' in desc) monitored[desc.monitored ? 'add' : 'delete'](name); - Object.defineProperty(Data.prototype, name, desc); - return Data.prototype[name]; // return the default value -} - -})(); - -// bililiteRange.find.js commit ef1c276 -'use strict'; - -(function(bililiteRange){ - -bililiteRange.createOption('dotall', {value: false}); -bililiteRange.createOption('global', {value: false}); -bililiteRange.createOption('ignorecase', {value: false}); -bililiteRange.createOption('magic', {value: true}); -bililiteRange.createOption('multiline', {value: false}); -bililiteRange.createOption('unicode', {value: false}); -bililiteRange.createOption('wrapscan', {value: true}); - -bililiteRange.bounds.find = function (name, restring, flags = ''){ - return find (this, restring, 'V'+flags); -}; - -bililiteRange.override('bounds', function (re, flags = ''){ - // duck typed RegExps are OK, allows for flags to be part of re - if (!(re instanceof Object && 'source' in re && 'flags' in re)) return this.super(...arguments); - return find (this, re.source, flags + re.flags); -}); - -bililiteRange.prototype.replace = function (search, replace, flags = ''){ - if (search instanceof Object && 'source' in search && 'flags' in search){ - // a RegExp or similar - flags = flags + search.flags; - search = search.source; - }else{ - search = search.toString(); - flags = 'V' + flags; + + // data for elements, similar to jQuery data, but allows for monitoring with custom events + const monitored = new Set(); + + function signalMonitor(prop, value, element) { + const attr = `data-${prop}`; + element.dispatchEvent( + new CustomEvent(attr, { bubbles: true, detail: value }) + ); + try { + element.setAttribute(attr, value); // illegal attribute names will throw. Ignore it + } finally { + /* ignore */ + } } - return this.text( - replaceprimitive (search, parseFlags(this, flags), this.all(), replace, this[0], this[1]), - { inputType: 'insertReplacementText' } - ); -} - -bililiteRange.createOption ('word', {value: /\b/}); -bililiteRange.createOption ('bigword', {value: /\s+/}); -bililiteRange.createOption ('sentence', {value: /\n\n|\.\s/}); -bililiteRange.createOption ('paragraph', {value: /\n\s*\n/}); -bililiteRange.createOption ('section', {value: /\n(|(-|\*|_){3,})\n/i}); -bililiteRange.createOption ('()', {value: [/\(/, /\)/] }); -bililiteRange.createOption ('[]', {value: [/\[/, /]/] }); -bililiteRange.createOption ('{}', {value: [/{/, /}/] }); -bililiteRange.createOption ('"', {value: [/"/, /"/] }); -bililiteRange.createOption ("'", {value: [/'/, /'/] }); - -bililiteRange.bounds.to = function(name, separator, outer = false){ - if (separator in this.data) separator = this.data[separator]; - if (separator.length == 2) separator = separator[1]; - if (!(separator instanceof RegExp)) separator = new RegExp (quoteRegExp (separator)); - // end of text counts as a separator - const match = findprimitive(`(${separator.source})|$`, 'g'+separator.flags, this.all(), this[1], this.length); - return this.bounds('union', outer ? match.index + match[0].length : match.index); -}; - -bililiteRange.bounds.from = function(name, separator, outer = false){ - if (separator in this.data) separator = this.data[separator]; - if (separator.length == 2) separator = separator[0]; - if (!(separator instanceof RegExp)) separator = new RegExp (quoteRegExp (separator)); - // start of text counts as a separator - const match = findprimitiveback(`(${separator.source})|^`, 'g'+separator.flags, this.all(), 0, this[0]); - return this.bounds('union', outer ? match.index : match.index + match[0].length); -}; - -bililiteRange.bounds.whole = function(name, separator, outer = false){ - if (separator in this.data) separator = this.data[separator]; - // if it's a two-part separator (like parentheses or quotes) then "outer" should include both. - return this.bounds('union', 'from', separator, outer && separator?.length == 2).bounds('union', 'to', separator, outer); -}; - -//------- private functions ------- - -function find (range, source, sourceflags){ - const { - backward, - magic, - restricted, - sticky, - wrapscan, - flags - } = parseFlags (range, sourceflags + 'g'); - if (!magic) source = quoteRegExp (source); - const findfunction = backward ? findprimitiveback : findprimitive; - let from, to; - if (restricted){ - from = range[0]; - to = range[1]; - }else if (backward){ - from = 0; - to = range[0]; - }else{ - from = range[1]; - to = range.length; + + function createDataObject(el) { + return (el[datakey] = new Proxy(new Data(el), { + set(obj, prop, value) { + obj[prop] = value; + if (monitored.has(prop)) signalMonitor(prop, value, obj.sourceElement); + return true; // in strict mode, 'set' returns a success flag + } + })); } - let match = findfunction (source, flags, range.all(), from, to); - if (!match && wrapscan && !sticky && !restricted){ - match = findfunction(source, flags, range.all(), 0, range.length); + + /** + @class + */ + function Data(el) { + Object.defineProperty(this, "sourceElement", { + value: el + }); } - range.match = match || false; // remember this for the caller - if (match) range.bounds([match.index, match.index+match[0].length]); // select the found string - return range; + + Data.prototype = {}; + // for use with ex options. JSON.stringify(range.data) should return only the options that were + // both defined with bililiteRange.option() *and* actually had a value set on this particular data object. + // JSON.stringify (range.data.all) should return all the options that were defined. + Object.defineProperties(Data.prototype, { + toJSON: { + value() { + let ret = {}; + for (let key in Data.prototype) + if (this.hasOwnProperty(key)) ret[key] = this[key]; + return ret; + } + }, + all: { + get() { + let ret = {}; + for (let key in Data.prototype) ret[key] = this[key]; + return ret; + } + }, + trigger: { + value() { + monitored.forEach((prop) => + signalMonitor(prop, this[prop], this.sourceElement) + ); + } + } + }); + + bililiteRange.createOption = function createOption(name, desc = {}) { + desc = Object.assign( + { + enumerable: true, // use these as the defaults + writable: true, + configurable: true + }, + Object.getOwnPropertyDescriptor(Data.prototype, name), + desc + ); + if ("monitored" in desc) monitored[desc.monitored ? "add" : "delete"](name); + Object.defineProperty(Data.prototype, name, desc); + return Data.prototype[name]; // return the default value + }; + + module.exports.Range = Range; + module.exports.InputRange = InputRange; + module.exports.W3CRange = W3CRange; } - -function parseFlags (range, flags){ - let flagobject = { - b: false, - g: range.data.global, - i: range.data.ignorecase, - m: range.data.multiline, - r: false, - s: range.data.dotall, - u: range.data.unicode, - v: range.data.magic, - w: range.data.wrapscan, - y: false +, +(require, module)=>{ +/************************************************* + * source: bililiteRange.find.js + * file: bililiteRange.find.js + * repo: + * commit: bcf2d15 + * version: 5.0.0 + * date: 2024-05-09 + ************************************************/ + const { bililiteRange } = require('./bililiteRange.js'); + + bililiteRange.createOption('dotall', {value: false}); + bililiteRange.createOption('global', {value: false}); + bililiteRange.createOption('ignorecase', {value: false}); + bililiteRange.createOption('magic', {value: true}); + bililiteRange.createOption('multiline', {value: false}); + bililiteRange.createOption('unicode', {value: false}); + bililiteRange.createOption('wrapscan', {value: true}); + + bililiteRange.bounds.find = function (name, restring, flags = ''){ + return find (this, restring, 'V'+flags); }; - flags.split('').forEach( flag => flagobject[flag.toLowerCase()] = flag === flag.toLowerCase() ); - return { - // these are the "real" flags - flags: (flagobject.g ? 'g' : '') + (flagobject.i ? 'i' : '') + (flagobject.m ? 'm' : '') + - (flagobject.s ? 's' : '') + (flagobject.u ? 'u' : '') + (flagobject.y ? 'y' : ''), - backward: flagobject.b, - global: flagobject.g, - magic: flagobject.v, - restricted: flagobject.r, - wrapscan: flagobject.w, - sticky: flagobject.y + + bililiteRange.override('bounds', function (re, flags = ''){ + // duck typed RegExps are OK, allows for flags to be part of re + if (!(re instanceof Object && 'source' in re && 'flags' in re)) return this.super(...arguments); + return find (this, re.source, flags + re.flags); + }); + + bililiteRange.prototype.replace = function (search, replace, flags = ''){ + if (search instanceof Object && 'source' in search && 'flags' in search){ + // a RegExp or similar + flags = flags + search.flags; + search = search.source; + }else{ + search = search.toString(); + flags = 'V' + flags; + } + return this.text( + replaceprimitive (search, parseFlags(this, flags), this.all(), replace, this[0], this[1]), + { inputType: 'insertReplacementText' } + ); + } + + bililiteRange.createOption ('word', {value: /\b/}); + bililiteRange.createOption ('bigword', {value: /\s+/}); + bililiteRange.createOption ('sentence', {value: /\n\n|\.\s/}); + bililiteRange.createOption ('paragraph', {value: /\n\s*\n/}); + bililiteRange.createOption ('section', {value: /\n(|(-|\*|_){3,})\n/i}); + bililiteRange.createOption ('()', {value: [/\(/, /\)/] }); + bililiteRange.createOption ('[]', {value: [/\[/, /]/] }); + bililiteRange.createOption ('{}', {value: [/{/, /}/] }); + bililiteRange.createOption ('"', {value: [/"/, /"/] }); + bililiteRange.createOption ("'", {value: [/'/, /'/] }); + + bililiteRange.bounds.to = function(name, separator, outer = false){ + if (separator in this.data) separator = this.data[separator]; + if (separator.length == 2) separator = separator[1]; + if (!(separator instanceof RegExp)) separator = new RegExp (quoteRegExp (separator)); + // end of text counts as a separator + const match = findprimitive(`(${separator.source})|$`, 'g'+separator.flags, this.all(), this[1], this.length); + return this.bounds('union', outer ? match.index + match[0].length : match.index); }; -} - -function quoteRegExp (source){ - // from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Escaping - return source.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); -} - -function findprimitive (source, flags, text, from, to){ - // code from https://github.com/idupree/bililiteRange/tree/findback-greedy-correctness - if (to < text.length){ - // make sure that there are at least length-to characters after the match - source = `(?:${source})(?=[^]{${text.length-to}})`; + + bililiteRange.bounds.from = function(name, separator, outer = false){ + if (separator in this.data) separator = this.data[separator]; + if (separator.length == 2) separator = separator[0]; + if (!(separator instanceof RegExp)) separator = new RegExp (quoteRegExp (separator)); + // start of text counts as a separator + const match = findprimitiveback(`(${separator.source})|^`, 'g'+separator.flags, this.all(), 0, this[0]); + return this.bounds('union', outer ? match.index : match.index + match[0].length); + }; + + bililiteRange.bounds.whole = function(name, separator, outer = false){ + if (separator in this.data) separator = this.data[separator]; + // if it's a two-part separator (like parentheses or quotes) then "outer" should include both. + return this.bounds('union', 'from', separator, outer && separator?.length == 2).bounds('union', 'to', separator, outer); + }; + + //------- private functions ------- + + function find (range, source, sourceflags){ + const { + backward, + magic, + restricted, + sticky, + wrapscan, + flags + } = parseFlags (range, sourceflags + 'g'); + if (!magic) source = quoteRegExp (source); + const findfunction = backward ? findprimitiveback : findprimitive; + let from, to; + if (restricted){ + from = range[0]; + to = range[1]; + }else if (backward){ + from = 0; + to = range[0]; + }else{ + from = range[1]; + to = range.length; + } + let match = findfunction (source, flags, range.all(), from, to); + if (!match && wrapscan && !sticky && !restricted){ + match = findfunction(source, flags, range.all(), 0, range.length); + } + range.match = match || false; // remember this for the caller + if (match) range.bounds([match.index, match.index+match[0].length]); // select the found string + return range; } - const re = new RegExp (source, flags); - re.lastIndex = from; - return re.exec(text); -} - -function findprimitiveback (source, flags, text, from, to){ - // code from https://github.com/idupree/bililiteRange/tree/findback-greedy-correctness - if (to < text.length){ - // make sure that there are at least length-to characters after the match - source = `(?:${source})(?=[^]{${text.length-to}})`; + + function parseFlags (range, flags){ + let flagobject = { + b: false, + g: range.data.global, + i: range.data.ignorecase, + m: range.data.multiline, + r: false, + s: range.data.dotall, + u: range.data.unicode, + v: range.data.magic, + w: range.data.wrapscan, + y: false + }; + flags.split('').forEach( flag => flagobject[flag.toLowerCase()] = flag === flag.toLowerCase() ); + return { + // these are the "real" flags + flags: (flagobject.g ? 'g' : '') + (flagobject.i ? 'i' : '') + (flagobject.m ? 'm' : '') + + (flagobject.s ? 's' : '') + (flagobject.u ? 'u' : '') + (flagobject.y ? 'y' : ''), + backward: flagobject.b, + global: flagobject.g, + magic: flagobject.v, + restricted: flagobject.r, + wrapscan: flagobject.w, + sticky: flagobject.y + }; + } + + function quoteRegExp (source){ + // from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Escaping + return source.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); } - if (/y/.test(flags)){ - // sticky. Only match the end of the string. - flags = flags.replace('y',''); - source = `(?:${source})(?![^]{${text.length-to+1}})`; // *don't* match too many characters - // this works even if $ won't, if multiline is true + + function findprimitive (source, flags, text, from, to){ + // code from https://github.com/idupree/bililiteRange/tree/findback-greedy-correctness + if (to < text.length){ + // make sure that there are at least length-to characters after the match + source = `(?:${source})(?=[^]{${text.length-to}})`; + } const re = new RegExp (source, flags); re.lastIndex = from; return re.exec(text); - }else{ - // no way to search backward; have to search forward until we fail - const re = new RegExp (source, flags); - re.lastIndex = from; - let match = false; - do { - var lastmatch = match; - match = re.exec(text); - if (match && re.lastIndex == match.index) ++re.lastIndex; // beware zero-length matches and infinite loops - }while (match); - return lastmatch; } + + function findprimitiveback (source, flags, text, from, to){ + // code from https://github.com/idupree/bililiteRange/tree/findback-greedy-correctness + if (to < text.length){ + // make sure that there are at least length-to characters after the match + source = `(?:${source})(?=[^]{${text.length-to}})`; + } + if (/y/.test(flags)){ + // sticky. Only match the end of the string. + flags = flags.replace('y',''); + source = `(?:${source})(?![^]{${text.length-to+1}})`; // *don't* match too many characters + // this works even if $ won't, if multiline is true + const re = new RegExp (source, flags); + re.lastIndex = from; + return re.exec(text); + }else{ + // no way to search backward; have to search forward until we fail + const re = new RegExp (source, flags); + re.lastIndex = from; + let match = false; + do { + var lastmatch = match; + match = re.exec(text); + if (match && re.lastIndex == match.index) ++re.lastIndex; // beware zero-length matches and infinite loops + }while (match); + return lastmatch; + } + } + + function replaceprimitive (search, flagobject, text, replace, from, to){ + if (!flagobject.magic) search = quoteRegExp (search); + if (from > 0){ + // make sure we have at least (from) characters before the match + search = `(?<=[^]{${from}})(?:${search})`; + } + if (to < text.length){ + // make sure we have at least (length - to) characters after the match + search = `(?:${search})(?=[^]{${text.length - to}})`; + } + if (flagobject.sticky && flagobject.backward){ + flagobject.flags = flagobject.flags.replace(/[gy]/g, ''); + // make sure we don't have too many characters after the match + search = `(?:${search})(?![^]{${text.length - to + 1}})`; + }else if (flagobject.backward && ! flagobject.global){ + // would anyone ever do this? Replace only the last match? + const match = findprimitiveback (search, flagobject.flags+'g', text, from, to); + if (!match) return text.slice (from, to); // no match, no change + search = `(?<=[^]{${match.index}})(?:${search})`; + } + const re = new RegExp (search, flagobject.flags); + re.lastIndex = from; // only relevant for sticky && !backward + // if to == length, then go to the end of the string,not to position 0! + return text.replace (re, replace).slice(from, to-text.length || undefined); + } + + module.exports = bililiteRange; } - -function replaceprimitive (search, flagobject, text, replace, from, to){ - if (!flagobject.magic) search = quoteRegExp (search); - if (from > 0){ - // make sure we have at least (from) characters before the match - search = `(?<=[^]{${from}})(?:${search})`; +, +(require, module)=>{ +/************************************************* + * source: bililiteRange.lines.js + * file: bililiteRange.lines.js + * repo: + * commit: bcf2d15 + * version: 5.0.0 + * date: 2024-05-09 + ************************************************/ + const { bililiteRange } = require('./bililiteRange.js'); + + + // a line goes from after the newline to before the next newline. The newline is not included in that line! It's + // a separator only. + bililiteRange.bounds.EOL = function () { + const nextnewline = this.all().indexOf('\n', this[1]); + if (nextnewline != -1) return nextnewline; + return this.bounds('end'); // no newline + }; + bililiteRange.bounds.BOL = function(){ + if (this[0] == 0) return 0; + const prevnewline = this.all().lastIndexOf('\n', this[0]-1); + if (prevnewline != -1) return prevnewline + 1; + return 0; // no newline + }; + bililiteRange.bounds.line = function (name, n, n2){ + if (n == null){ + // select the entire line or lines including the newline + return this.bounds('union', 'BOL').bounds('union', 'EOL'); + }else if (n2 == null){ + // select one line. Note that it is 1-indexed, the way ex does it! + n = parseInt(n); + if (isNaN(n)) return this.bounds(); + if (n < 1) return [0,0]; + const mynewline = (new RegExp(`^(.*\n){${n}}`)).exec(this.all()); // find the nth newline + if (mynewline == null){ + this.bounds('end'); + // if this is the last line but it doesn't end with a newline, then accept the whole line + if (this.all().split('\n').length == n) this.bounds('line'); + return this; + } + return this.bounds(mynewline[0].length-1).bounds('line'); + }else{ + return this.bounds('line', n).bounds('union', 'line', n2); + } + }; + bililiteRange.bounds.andnewline = function(){ + // if we want a "line" to include the following newline, use this + if (this.all().charAt(this[1]) == '\n') return this.bounds('union', this[1]+1); + } + bililiteRange.bounds.char = function (name, n){ + // move to character position n in the line of the start of this range. + this.bounds('EOL'); + this.bounds('BOL').bounds('line'); + if (this.bounds('BOL').bounds('line').text().length < n){ + return this.bounds('EOL'); + }else{ + return this[0] + n; + } + }; + + bililiteRange.createOption ('autoindent', {value: false}); + bililiteRange.override ('text', function (text, opts = {}){ + if ( text === undefined ) return this.super(); + if (opts.ownline && text[0] != '\n' && this[1] > 0) text = `\n${text}`; + if (opts.ownline && this.all().charAt(this[1]) != '\n') text = `${text}\n`; + if (opts.autoindent == 'invert') opts.autoindent = !this.data.autoindent; + if (opts.autoindent || (opts.autoindent == null && this.data.autoindent && opts.inputType == 'insertLineBreak')){ + text = indent(text, this.indentation()); + } + return this.super(text, opts); + }); + + bililiteRange.createOption ('tabsize', { value: 2, monitored: true }); // 8 is the browser default + bililiteRange.addStartupHook ( (element, range, data) => { + element.style.tabSize = element.style.MozTabSize = data.tabsize; // the initial value will be set before we start listening + range.listen('data-tabsize', evt => element.style.tabSize = element.style.MozTabSize = evt.detail); + }); + + bililiteRange.extend({ + char: function(){ + return this[0] - this.clone().bounds('BOL')[0]; + }, + indent: function (tabs){ + // tabs is the string to insert before each line of the range + this.bounds('union', 'BOL'); + // need to make sure we add the tabs at the start of the line in addition to after each newline + return this.text(tabs + indent (this.text(), tabs), {select: 'all', inputType: 'insertReplacementText'}); + }, + indentation: function(){ + // returns the whitespace at the start of this line + return /^\s*/.exec(this.clone().bounds('line').text())[0]; + }, + line: function(){ + // return the line number of the *start* of the bounds. Note that it is 1-indexed, the way ex writes it! + // just count newlines before this.bounds + return this.all().slice(0, this[0]).split('\n').length; + }, + lines: function(){ + const start = this.line(); + const end = this.clone().bounds('endbounds').line(); + return [start, end]; + }, + unindent: function (n, tabsize){ + // remove n tabs or sets of tabsize spaces from the beginning of each line + tabsize = tabsize || this.data.tabsize; + return this.bounds('line').text(unindent(this.text(), n, tabsize), {select: 'all', inputType: 'insertReplacementText'}); + }, + }); + + bililiteRange.sendkeys['{ArrowUp}'] = bililiteRange.sendkeys['{uparrow}'] = function (rng){ + const c = rng.char(); + rng.bounds('line', rng.line()-1).bounds('char', c); + }; + bililiteRange.sendkeys['{ArrowDown}'] = bililiteRange.sendkeys['{downarrow}'] = function (rng){ + const c = rng.char(); + rng.bounds('line', rng.line()+1).bounds('char', c); + }; + bililiteRange.sendkeys['{Home}'] = function (rng){ + rng.bounds('BOL'); + }; + bililiteRange.sendkeys['{End}'] = function (rng){ + rng.bounds('EOL'); + }; + + // utilities + + function indent(text, tabs){ + return text.replace(/\n/g, '\n' + tabs); } - if (to < text.length){ - // make sure we have at least (length - to) characters after the match - search = `(?:${search})(?=[^]{${text.length - to}})`; + function unindent(str, count, tabsize){ + // count can be an integer >= 0 or Infinity. + // (We delete up to 'count' tabs at the beginning of each line.) + // If invalid, defaults to 1. + // + // tabsize can be an integer >= 1. + // (The number of spaces to consider a single tab.) + tabsize = Math.round(tabsize); + count = Math.round(count); + if (!isFinite(tabsize) || tabsize < 1) tabsize = 4; + if (isNaN(count) || count < 0) count = 1; + if (!isFinite(count)) count = ''; + const restart = new RegExp(`^(?:\t| {${tabsize}}){1,${count}}`, 'g'); + const remiddle = new RegExp(`(\\n)(?:\t| {${tabsize}}){1,${count}}`, 'g'); + return str.replace(restart, '').replace(remiddle, '$1'); } - if (flagobject.sticky && flagobject.backward){ - flagobject.flags = flagobject.flags.replace(/[gy]/g, ''); - // make sure we don't have too many characters after the match - search = `(?:${search})(?![^]{${text.length - to + 1}})`; - }else if (flagobject.backward && ! flagobject.global){ - // would anyone ever do this? Replace only the last match? - const match = findprimitiveback (search, flagobject.flags+'g', text, from, to); - if (!match) return text.slice (from, to); // no match, no change - search = `(?<=[^]{${match.index}})(?:${search})`; + + module.exports = bililiteRange; +} +, +(require, module)=>{ +/************************************************* + * source: jquery.sendkeys.js + * file: jquery.sendkeys.js + * repo: + * commit: bcf2d15 + * version: 5.0.0 + * date: 2024-05-09 + ************************************************/ + 'use strict'; + + (function($){ + + $.fn.sendkeys = function (x){ + return this.each( function(){ + bililiteRange(this).bounds('selection').sendkeys(x).select(); + this.focus(); + }); + }; // sendkeys + + // add a default handler for keydowns so that we can send keystrokes, even though code-generated events + // are untrusted (http://www.w3.org/TR/DOM-Level-3-Events/#trusted-events) + // documentation of special event handlers is at http://learn.jquery.com/events/event-extensions/ + $.event.special.keydown = $.event.special.keydown || {}; + $.event.special.keydown._default = function (evt){ + if (evt.isTrusted) return false; + if (evt.key == null) return false; // nothing to print. Use the keymap plugin to set this + if (evt.ctrlKey || evt.altKey || evt.metaKey) return false; // only deal with printable characters. + var target = evt.target; + if (target.isContentEditable || target.nodeName == 'INPUT' || target.nodeName == 'TEXTAREA') { + // only insert into editable elements + var key = evt.key; + if (key.length > 1 && key.charAt(0) != '{') key = '{'+key+'}'; // sendkeys notation + $(target).sendkeys(key); + return true; + } + return false; } - const re = new RegExp (search, flagobject.flags); - re.lastIndex = from; // only relevant for sticky && !backward - // if to == length, then go to the end of the string,not to position 0! - return text.replace (re, replace).slice(from, to-text.length || undefined); + })(jQuery) } -})(bililiteRange); +]); diff --git a/dist/editor.js b/dist/editor.js deleted file mode 100644 index 3c94ea4..0000000 --- a/dist/editor.js +++ /dev/null @@ -1,2546 +0,0 @@ -// editor.js 2023-03-05 - -// dwachss/historystack/history.js commit 806bac52 -// Implements the History interface for use with things other than browser history. I don't know why they won't let us use -'use strict'; - -function History (inititalstate){ - this._length = 1; - this._index = 1; - this._states = [undefined, inititalstate]; // easiest on the math to use 1-index array -} - -History.prototype = { - back() { return this.go(-1) }, - forward() {return this.go(1) }, - go(n) { - this._index += n; - if (this._index < 1 ) this._index = 1; - if (this._index > this._length ) this._index = this._length; - return this.state; - }, - pushState(state) { - this._length = ++this._index; - return this.replaceState(state); - }, - replaceState (state) { return this._states[this._index] = state }, - get length() { return this._length }, - get state() { return this._states[this._index] }, - // not part of the interface but useful nonetheless - get atStart() { return this._index == 1 }, - get atEnd() { return this._index == this._length } -} - - - -// dwachss/keymap/keymap.js commit 383f82cc -// allows mapping specific key sequences to actions - -'use strict'; - -(()=>{ -const canonicalSpellings = 'ArrowDown ArrowLeft ArrowRight ArrowUp Backspace CapsLock Delete End Enter Escape Home Insert NumLock PageDown PageUp Pause ScrollLock Space Tab'.split(' '); -const lowercaseSpellings = canonicalSpellings.map(s => new RegExp(`\\b${s}\\b`, 'gi')); -// Microsoft SendKeys notation, https://learn.microsoft.com/en-us/office/vba/language/reference/user-interface-help/sendkeys-statement -// 'Return' is from VIM, https://vimhelp.org/intro.txt.html#%3CReturn%3E -const alternateSpellings = 'Down Left Right Up BS CapsLock Del End Return Esc Home Ins NumLock PGDN PGUP Break ScrollLock Spacebar Tab'.split(' ').map(s => new RegExp(`\\b${s}\\b`, 'gi')); -// modifier keys. VIM uses '-', jquery.hotkeys uses '+', https://github.com/jresig/jquery.hotkeys -// Not using meta- -const modifiers = [[/s(hift)?[+-]/gi, '+'], [/c(trl)?[+-]/gi, '^'], [/a(lt)?[+-]/gi, '%']]; - -function normalize (keyDescriptor){ - keyDescriptor = keyDescriptor.trim().replaceAll(/ +/g, ' '); // collapse multiple spaces - lowercaseSpellings.forEach( (re, i) => { keyDescriptor = keyDescriptor.replaceAll(re, canonicalSpellings[i]) } ); - alternateSpellings.forEach( (re, i) => { keyDescriptor = keyDescriptor.replaceAll(re, canonicalSpellings[i]) } ); - // VIM key descriptors are enclosed in angle brackets; sendkeys are enclosed in braces - keyDescriptor = keyDescriptor.replaceAll(/(?<= |^)<([^>]+)>(?= |$)/g, '$1'); - keyDescriptor = keyDescriptor.replaceAll(/{([^}]+|})}/g, '$1'); - // uppercase function keys - keyDescriptor = keyDescriptor.replaceAll(/f(\d+)/g, 'F$1'); - // it's easiest to turn modifiers into single keys, then reorder them and rename them - modifiers.forEach( pair => { keyDescriptor = keyDescriptor.replaceAll(...pair) } ); - // normalize the order of ctrl-alt-shift - keyDescriptor = keyDescriptor.replaceAll( - /[+^%]+(?! |$)/g, // don't match a final [+^%], since that will be an actual character - match => (/\^/.test(match) ? 'ctrl-' : '') + (/%/.test(match) ? 'alt-' : '') + (/\+/.test(match) ? 'shift-' : '') - ) - keyDescriptor = keyDescriptor.replaceAll(/shift-([a-zA-Z])\b/g, (match, letter) => letter.toUpperCase() ); - return keyDescriptor; -} - -// generates successive prefixes of lists of keyDescriptors -// turns 'alt-f x Enter' into [/^alt-f$/, /^alt-f x$/, /^alt-f x Enter$/] -// and /alt-x f\d+ (Enter|Escape)/i into [/^alt-x$/i, /^alt-x f\d+$/i, /^alt-x f\d+ (Enter|Escape)$/i] -function prefixREs(strOrRE){ - let sources, ignoreCase; - if (!strOrRE.source){ - // escape RegExp from https://developer.mozilla.org/en/docs/Web/JavaScript/Guide/Regular_Expressions - sources = strOrRE.toString().trim().split(/\s+/). - map(normalize). - map(s => s.replaceAll(/[.*+?^=!:${}()|\[\]\/\\]/g, "\\$&")); - ignoreCase = ''; - }else{ - sources = strOrRE.source.trim().split(/\s+/); - ignoreCase = strOrRE.ignoreCase ? 'i' : ''; - } - return sources.reduce ( (accumulator, currentValue, i) => { - if (i == 0) return [RegExp(`^${currentValue}$`, ignoreCase)]; // ^...$ to match the entire key - const source = accumulator[i-1].source.replaceAll(/^\^|\$$/g,''); // strip off the previous ^...$ - accumulator.push( new RegExp( `^${source} ${currentValue}$`, ignoreCase ) ); - return accumulator; - }, []); -}; - -// ANSI keyboard codes -const specialKeys = { - 'Backquote': '`', - 'shift-Backquote': '~', - 'shift-1': '!', - 'shift-2': '@', - 'shift-3': '#', - 'shift-4': '$', - 'shift-5': '%', - 'shift-6': '^', - 'shift-7': '&', - 'shift-8': '*', - 'shift-9': '(', - 'shift-0': ')', - 'Minus': '-', - 'shift-Minus': '_', - 'Equal': '=', - 'shift-Equal': '+', - 'BracketRight': '[', - 'shift-BracketRight': '{', - 'BracketLeft': ']', - 'shift-BracketLeft': '}', - 'Backslash': '\\', - 'shift-Backslash': '|', - 'Semicolon': ';', - 'shift-Semicolon': ':', - 'Quote': "'", - 'shift-Quote': '"', - 'Comma': ',', - 'shift-Comma': '<', - 'Period': '.', - 'shift-Period': '>', - 'Slash': '/', - 'shift-Slash': '?', -}; - -function addKeyDescriptor(evt){ - // create a "keyDescriptor" field in evt that represents the keystroke as a whole: "ctrl-alt-Delete" for instance. - const {code, shiftKey, ctrlKey, altKey} = evt; - let key = evt.key; // needs to be variable - if (!key || /^(?:shift|control|meta|alt)$/i.test(key)) return evt; // ignore undefined or modifier keys alone - // we use spaces to delimit keystrokes, so this needs to be changed; use the code - if (key == ' ') key = 'Space'; - // In general, use the key field. However, modified letters (ctrl- or alt-) use the code field - // so that ctrl-A is the A key even on non-English keyboards. - if (ctrlKey || altKey){ - if (/^(Key|Digit)\w$/.test(code)){ - evt.keyDescriptor = code.charAt(code.length-1)[shiftKey ? 'toUpperCase' : 'toLowerCase'](); - }else if (/^Numpad/.test(code)){ - evt.keyDescriptor = key; - }else{ - evt.keyDescriptor = code; - } - if (!/^[a-zA-Z]$/.test(evt.keyDescriptor) && shiftKey) evt.keyDescriptor = 'shift-'+evt.keyDescriptor; - if (evt.keyDescriptor in specialKeys) evt.keyDescriptor = specialKeys[evt.keyDescriptor]; - }else{ - evt.keyDescriptor = key; - // printable characters should ignore the shift; that's incorporated into the key generated - if (key.length !== 1 && shiftKey) evt.keymap = 'shift-'+evt.keymap; - } - if (altKey) evt.keyDescriptor = 'alt-'+evt.keyDescriptor; - if (ctrlKey) evt.keyDescriptor = 'ctrl-'+evt.keyDescriptor; - return evt; -} - -function keymap (keyDescriptorTarget, handler, prefixHandler = ( evt => { evt.preventDefault() } )){ - const prefixes = prefixREs(keyDescriptorTarget); - const prefixSymbol = Symbol(); - const newHandler = function (evt){ - const keyDescriptorSource = addKeyDescriptor(evt).keyDescriptor; - if (!keyDescriptorSource) return; // it is a modifier key - evt.keymapFilter = keyDescriptorTarget; - const el = evt.currentTarget; - let currentSequence = keyDescriptorSource; - if (el[prefixSymbol]) currentSequence = `${el[prefixSymbol]} ${currentSequence}`; - while (currentSequence){ - const length = currentSequence.split(' ').length; - if ( prefixes[length-1].test(currentSequence) ){ - // we have a match - evt.keymapSequence = el[prefixSymbol] = currentSequence; - prefixHandler.apply(this, arguments); - if (length == prefixes.length){ - // we have a match for the whole thing - delete el[prefixSymbol]; - return handler.apply(this, arguments); - } - return; - } - // if we get here, then we do not have a match. Maybe we started too soon (looking for 'a b c', got 'a a b c' and matched the beginning of the - // sequence at the first 'a', but that was wrong, so take off the first 'a' and try again - currentSequence = currentSequence.replace(/^\S+[ ]?/, ''); - } - delete el[prefixSymbol]; // no matches at all. - }; - newHandler.keymapPrefix = prefixSymbol; - newHandler.keymapFilter = keyDescriptorTarget; - return newHandler; -} - -globalThis.keymap = keymap; -keymap.normalize = normalize; -keymap.addKeyDescriptor = addKeyDescriptor; - -})(); - - -// dwachss/status/status.js commit 6a9da212 -// Promise.alert and Promise.prompt allow for creating a "status bar" for interacting with the user - -'use strict'; - -Promise.alert = (message, container = globalThis) => { - return Promise.resolve(message).then( message => { - if (message instanceof Error) throw message; - if (container instanceof HTMLElement){ - displayMessage(container, message, Promise.alert.classes.success); - }else{ - (container.log ?? container.alert)(message); - } - return message; - }).catch(error => { - if (container instanceof HTMLElement){ - displayMessage(container, error, Promise.alert.classes.failure); - }else{ - const message = container.error ? error : `\u26A0\uFE0F ${error}`; // add warning emoji - (container.error ?? container.alert)(message); - } - return error; // create a fulfilled promise with the error, if the user wants to do something more - }); - - function displayMessage (container, message, classname = Promise.alert.classes.success){ - const span = document.createElement('span'); - span.classList.add(classname); - span.textContent = message; - span.setAttribute('role', 'alert'); - span.ontransitionend = evt => span.remove(); - container.prepend(span); - setTimeout(()=>span.classList.add(Promise.alert.classes.hidden), 10); - } - -} - -Object.defineProperty( Promise.prototype, 'alert', { - value: function (container) { return Promise.alert(this, container) } -}); - -Promise.alert.classes = { - success: 'success', - failure: 'failure', - hidden: 'hidden' -}; - -Promise.prompt = (promptMessage = '', container = globalThis, defaultValue = '') => { - if (container instanceof HTMLElement){ - return new Promise ((resolve, reject) => displayPrompt(resolve, reject)). - finally( () => container.querySelectorAll('label.prompt').forEach( el => el.remove() ) ); - }else{ - return new Promise ((resolve, reject) => { - const response = container.prompt(promptMessage, defaultValue); - if (response === null) reject (new Error(Promise.prompt.cancelMessage)); - resolve(response); - }); - } - - function history(){ - const key = Symbol.for('Promise.prompt.history'); - if (key in container) return container[key]; - try{ - return container[key] = new History(defaultValue); - }catch{ - // if my history stack is not implemented, the original History constructor will throw a TypeError - return null; - } - } - - function displayPrompt(resolve, reject){ - container.querySelectorAll('label.prompt').forEach( el => el.remove() ); // remove any old elements - const label = document.createElement('label'); - label.className = 'prompt'; - label.append(document.createElement('strong'), document.createElement('input')); - label.querySelector('strong').textContent = promptMessage; - label.querySelector('input').value = defaultValue; - label.querySelector('input').addEventListener('keydown', function(evt) { - switch (evt.key){ - case 'Enter': - evt.preventDefault(); - resolve(this.value); - break; - case 'Escape': - evt.preventDefault(); - reject (new Error(Promise.prompt.cancelMessage)); - break; - } - }); - const h = history(); - if (h) label.querySelector('input').addEventListener('keydown', function(evt) { - switch (evt.key){ - case 'Enter': - h.pushState(this.value); - break; - case 'ArrowUp': - if (h.atEnd){ - h.pushState(this.value); - h.back(); - } - this.value = h.state; - h.back(); - evt.preventDefault(); - break; - case 'ArrowDown': - this.value = h.forward(); - evt.preventDefault(); - break; - } - }); - container.prepend(label); - label.querySelector('input').focus(); - } - -} - -Promise.prompt.cancelMessage = "User Cancelled"; - - -// dwachss/toolbar/toolbar.js commit 0164cf02 -'use strict'; - -function Toolbar (container, target, func, label){ - this._container = container; - this._func = func.bind(target); - container.setAttribute('role', 'toolbar'); - if (label) container.setAttribute('aria-label', label); - // use ARIA (https://www.w3.org/TR/wai-aria-practices/examples/toolbar/toolbar.html ) to - // tie the toolbar to the target - let id = target.getAttribute('id'); - if (!id) { - id = 'toolbar-target-'+Math.random().toString(36).slice(2); // random enough string - target.setAttribute('id', id); - } - let otherToolbar = document.querySelector(`[aria-controls=${id}]`); - container.setAttribute('aria-controls', id); - // only have one toolbar capturing the context menu - if (!otherToolbar){ - target.addEventListener('keyup', evt => { - if (evt.key == 'ContextMenu'){ - if ( container.children.length == 0 ) return; - container.querySelector('[tabindex="0"]').focus(); - evt.preventDefault(); - return false; - } - }); - container.addEventListener('contextmenu', evt => { - // Firefox fires the context menu on the *container* when focused with the event listener above. - // Just disable the keyboard menu button (right-click works fine) - if (evt.button == 0) evt.preventDefault(); - }); - container.classList.add('capturing-menu'); - } - container.addEventListener('keydown', evt => { - if (evt.key == 'Escape') target.focus(); - if (/^Key[A-Z]$/.test(evt.code)){ - const index = evt.code.charCodeAt(3) - 'A'.charCodeAt(0) + 1; - const button = container.querySelector(`button:nth-of-type(${index})`); - if (button) { - button.dispatchEvent (new MouseEvent('click')); - button.classList.add('highlight'); - setTimeout( ()=> button.classList.remove('highlight'), 400); - } - } - // https://www.w3.org/TR/wai-aria-practices/#kbd_roving_tabindex - let focusedButton = container.querySelector('[tabindex="0"]'); - if (!focusedButton) return; - let tabbables = container.querySelectorAll('[tabindex]'); - let i = [].indexOf.call(tabbables, focusedButton); - if (evt.key == 'ArrowRight') { - ++i; - if (i >= tabbables.length ) i = 0; // ahould be at least one element, the focused one - focusedButton.setAttribute ('tabindex', -1); - tabbables[i].setAttribute ('tabindex', 0); - tabbables[i].focus(); - } - if (evt.key == 'ArrowLeft') { - --i; - if (i < 0 ) i = tabbables.length - 1; // ahould be at least one element, the focused one - focusedButton.setAttribute ('tabindex', -1); - tabbables[i].setAttribute ('tabindex', 0); - tabbables[i].focus(); - } - // Firefox wants to insert the key into the *target* - // So any printable character (length == 1) is to be ignored. - if (evt.key.length == 1) evt.preventDefault(); - }); -} - -Toolbar.for = function(el){ - const id = el.getAttribute('id'); - if (!id) return null; - return document.querySelector(`[aria-controls=${id}]`); -} - -Toolbar.getAttribute = function (el, attr) { - if (/^style\./.test(attr)){ - attr = attr.slice(6).replace (/-[a-z]/g, x => x.toUpperCase() ); // make sure it's camel case - return el.ownerDocument.defaultView.getComputedStyle(el)[attr]; - }else{ - return el.getAttribute(attr); - } -} - -Toolbar.setAttribute = function (el, attr, state) { - if (/^style\./.test(attr)){ - attr = attr.slice(6).replace (/-[a-z]/g, x => x.toUpperCase() ); // make sure it's camel case - el.style[attr] = state; - }else{ - el.setAttribute(attr, state); - } -}; - -Toolbar.toggleAttribute = function (el, attr, states){ - Toolbar.setAttribute (el, attr, Toolbar.getAttribute(el, attr) == states[0] ? states[1] : states[0]) -}; - -Toolbar.prototype = { - button(name, command = name, title) { - let button = this._container.querySelector(`button[name=${JSON.stringify(name)}]`); - if (!button){ - this._container.insertAdjacentHTML('beforeend', '