This is an automated email from the ASF dual-hosted git repository. gregdove pushed a commit to branch develop in repository https://gitbox.apache.org/repos/asf/royale-asjs.git
commit 463cb05bae25f4d6fe9e7d3c30fe04ab61e59178 Author: greg-dove <[email protected]> AuthorDate: Thu Sep 1 14:58:22 2022 +1200 Adding an initial Simple Text Highlighter component (JS-only functionality, compiles-only for SWF) --- .../Basic/src/main/resources/basic-manifest.xml | 2 + .../projects/Basic/src/main/resources/defaults.css | 19 + .../projects/Basic/src/main/royale/BasicClasses.as | 2 + .../apache/royale/html/SimpleTextHighlighter.as | 557 +++++++++++++++++++++ .../html/supportClasses/HighlightTextSpan.as | 256 ++++++++++ 5 files changed, 836 insertions(+) diff --git a/frameworks/projects/Basic/src/main/resources/basic-manifest.xml b/frameworks/projects/Basic/src/main/resources/basic-manifest.xml index 64d184c470..e800947b36 100644 --- a/frameworks/projects/Basic/src/main/resources/basic-manifest.xml +++ b/frameworks/projects/Basic/src/main/resources/basic-manifest.xml @@ -303,6 +303,8 @@ <component id="BrowserRouter" class="org.apache.royale.routing.BrowserRouter"/> <component id="UIGraphicsBase" class="org.apache.royale.display.UIGraphicsBase"/> + + <component id="SimpleTextHighlighter" class="org.apache.royale.html.SimpleTextHighlighter"/> <component id="CollectionSelectedItemByField" class="org.apache.royale.html.beads.CollectionSelectedItemByField"/> <component id="ErrorImage" class="org.apache.royale.html.beads.ErrorImage"/> diff --git a/frameworks/projects/Basic/src/main/resources/defaults.css b/frameworks/projects/Basic/src/main/resources/defaults.css index 4ed871e658..260aa56397 100644 --- a/frameworks/projects/Basic/src/main/resources/defaults.css +++ b/frameworks/projects/Basic/src/main/resources/defaults.css @@ -544,6 +544,8 @@ TableCell /* use browser default style */ } + + MultiSelectionTree { IBeadModel: ClassReference("org.apache.royale.html.beads.models.MultiSelectionTreeModel"); @@ -827,6 +829,23 @@ global effectTimerInterval: 10; } + +.SimpleTextHighlighter { + border: 1px #808080 solid; + white-space: pre-wrap; + overflow-y: auto; +} + +.SimpleTextHighlighter *:focus-visible { + outline: none; +} + +.HighlightTextSpan { + background-color: #00ffff; + border-radius: 2px; +} + + @media -royale-swf { /* Global style declaration for Flash only. This will effectively be the same diff --git a/frameworks/projects/Basic/src/main/royale/BasicClasses.as b/frameworks/projects/Basic/src/main/royale/BasicClasses.as index 670ce46cdc..fab74b8242 100644 --- a/frameworks/projects/Basic/src/main/royale/BasicClasses.as +++ b/frameworks/projects/Basic/src/main/royale/BasicClasses.as @@ -361,6 +361,8 @@ internal class BasicClasses import org.apache.royale.html.util.DialogPolyfill; DialogPolyfill; import org.apache.royale.html.beads.OverflowTooltipNeeded; OverflowTooltipNeeded; } + + import org.apache.royale.html.SimpleTextHighlighter; SimpleTextHighlighter; } } diff --git a/frameworks/projects/Basic/src/main/royale/org/apache/royale/html/SimpleTextHighlighter.as b/frameworks/projects/Basic/src/main/royale/org/apache/royale/html/SimpleTextHighlighter.as new file mode 100644 index 0000000000..29c5d313c0 --- /dev/null +++ b/frameworks/projects/Basic/src/main/royale/org/apache/royale/html/SimpleTextHighlighter.as @@ -0,0 +1,557 @@ +//////////////////////////////////////////////////////////////////////////////// +// +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//////////////////////////////////////////////////////////////////////////////// +package org.apache.royale.html +{ + import org.apache.royale.core.ITextModel; + import org.apache.royale.core.UIBase; + import org.apache.royale.events.Event; + import org.apache.royale.events.IEventDispatcher; + import org.apache.royale.html.supportClasses.HighlightTextSpan; + + COMPILE::JS + { + import goog.events; + import org.apache.royale.core.WrappedHTMLElement; + import org.apache.royale.html.util.addElementToWrapper; + } + + /** + * Dispatched when the user changes the text. + * + * @langversion 3.0 + * @playerversion Flash 10.2 + * @playerversion AIR 2.6 + * @productversion Royale 0.9.0 + */ + [Event(name="change", type="org.apache.royale.events.Event")] + + /** + * This class is a utility class for simple highlighting of + * editable text. + * + * @toplevel + * @langversion 3.0 + * @playerversion Flash 10.2 + * @playerversion AIR 2.6 + * @productversion Royale 0.0 + */ + public class SimpleTextHighlighter extends UIBase + { + + + + + + /** + * Constructor. + * + * @langversion 3.0 + * @playerversion Flash 10.2 + * @playerversion AIR 2.6 + * @productversion Royale 0.0 + */ + public function SimpleTextHighlighter() + { + super(); + + COMPILE::SWF + { + model.addEventListener("textChange", textChangeHandler); + } + } + + private var _parentRef:Object; + + + COMPILE::JS + private var _textContainer:HTMLSpanElement; + + private var _textLength:uint; + + private var _lengthValid:Boolean = true; + + private function textLength():uint{ + if (!_lengthValid) { + _textLength = text.length; + _lengthValid = true; + } + return _textLength; + } + + /** + * @copy org.apache.royale.html.Label#text + * + * @langversion 3.0 + * @playerversion Flash 10.2 + * @playerversion AIR 2.6 + * @productversion Royale 0.0 + * @royaleignorecoercion HTMLTextAreaElement + */ + [Bindable(event="change")] + public function get text():String + { + COMPILE::SWF + { + return ITextModel(model).text; + } + COMPILE::JS + { + return _textContainer.textContent; + } + } + + /** + * @private + * @royaleignorecoercion HTMLDivElement + */ + public function set text(value:String):void + { + COMPILE::SWF + { + inSetter = true; + ITextModel(model).text = value; + inSetter = false; + } + COMPILE::JS + { + if (value != _textContainer.textContent) { + _textContainer.textContent = value; + dispatchEvent(new Event('textChange')); + } + } + + _lengthValid = false; + } + + + protected var _highLights:Array = []; + + public function removeHighlights():void{ + COMPILE::JS + { + + //this will quickly remove all html (highlight blocks) + var selection:Selection = window.getSelection(); + var reselect:Boolean; + var offset:uint = 0; + var end:uint = 0; + if (selection.rangeCount) { + var selectionRange:Range = selection.getRangeAt(0); + if (_textContainer == selectionRange.commonAncestorContainer || _textContainer.contains(selectionRange.commonAncestorContainer)) { + var foundStart:Boolean; + reselect = true; + var node:Node = _textContainer.firstChild; + while(node) { + var tn:Node = node.nodeType == Node.TEXT_NODE ? node : node.firstChild; + if (!foundStart) { + if (selectionRange.startContainer == node || selectionRange.startContainer == tn) { + foundStart = true; + offset += selectionRange.startOffset; + + if (selectionRange.endContainer == node || selectionRange.endContainer == tn) { + end = offset + (selectionRange.endOffset - selectionRange.startOffset); + break; + } else { + end += node.textContent.length; + } + } else { + offset += node.textContent.length; + end = offset; + } + } else { + if (selectionRange.endContainer == node || selectionRange.endContainer == tn) { + end += selectionRange.endOffset; + break; + } else { + end += node.textContent.length; + } + } + node = node.nextSibling; + } + } + } + + var plainText:String = text; + _textContainer.textContent = plainText; + _highLights.length = 0; + if (reselect) { + selectionRange = document.createRange(); + node = _textContainer.firstChild ? _textContainer.firstChild : _textContainer; + selectionRange.setStart(node,offset); + selectionRange.setEnd(node,end); + selection.removeAllRanges(); + selection.addRange(selectionRange); + } + } + } + + /** + * + * can be overridden in subclasses + */ + protected function createHighlightSpan(begin:int, end:int):HighlightTextSpan{ + return new HighlightTextSpan(begin,end,_parentRef); + } + + private var _autoRemoveHighlightChanges:Boolean; + public function get autoRemoveHighlightChanges():Boolean{ + return _autoRemoveHighlightChanges + } + + public function set autoRemoveHighlightChanges(value:Boolean):void{ + _autoRemoveHighlightChanges = value; + } + + /** + * @royaleignorecoercion org.apache.royale.core.WrappedHTMLElement + */ + private function restoreIfNeeded():void{ + COMPILE::JS{ + //"undo" editing can restore items that were previously 'removed' + var l:uint = _highLights.length; + if (_textContainer.childElementCount > l) { + var collect:Array = []; + var wrappedElement:WrappedHTMLElement = WrappedHTMLElement(_textContainer.firstElementChild); + while (wrappedElement) { + var highlight:HighlightTextSpan = wrappedElement.royale_wrapper as HighlightTextSpan; + if (highlight) collect.push(highlight); + wrappedElement = WrappedHTMLElement(wrappedElement.nextElementSibling) + } + _highLights = collect; + } + } + } + + + private function performAutoRemove():void{ + var l:uint = _highLights.length; + if (l) { + var retained:Array = []; + for (var i:int = 0;i<l;i++) { + var removeCheck:HighlightTextSpan = _highLights[i] as HighlightTextSpan; + if (_autoRemoveHighlightChanges) { + if (removeCheck.isUnchanged) { + retained.push(removeCheck) + removeCheck = null; + } + } else { + if (removeCheck.isPresent) { + retained.push(removeCheck) + removeCheck = null; + } + } + if (removeCheck) { + removeCheck.unapply(); + } + } + _highLights = retained; + } + } + + /** + * + * Utility method for unHighlighting text. + * + * returns true if highlighting occurred, false otherwise + */ + public function removeHighlightForText(string:String, fromIndex:uint=0, all:Boolean=false):Boolean{ + var l:uint = _highLights.length; + var success:Boolean; + if (l && string) { + if (string) { + var idx:int = text.indexOf(string,fromIndex); + while (idx != -1) { + success = removeHighlightForRange(idx) || success; + if (all || !success) { + idx = text.indexOf(string,idx + string.length); + } else { + break; + } + } + } + } + return success; + } + + + public function removeHighlightForRange(containsIndex:uint):Boolean{ + var l:uint = _highLights.length; + if (l) { + for (var i:int = 0;i<l;i++) { + var highlight:HighlightTextSpan = _highLights[i] as HighlightTextSpan; + if (highlight.containsIndex(containsIndex)) { + _highLights.splice(i,1); + highlight.unapply(); + return true; + break; + } + } + } + return false; + } + + + /** + * + * Utility method for highlighting text. + * + * returns true if highlighting occurred, false otherwise + */ + public function highlightText(string:String, fromIndex:uint=0, all:Boolean=false):Boolean{ + var ret:Boolean; + if (string) { + var text:String = this.text; + var idx:int = text.indexOf(string,fromIndex); + while (idx != -1) { + ret = highlightOffsetRange(idx, string.length, !all) || ret; + if (all && text.length > idx + string.length) { + idx = text.indexOf(string, idx + string.length); + } else idx = -1; + } + } + return ret; + } + + /** + * + * This method uses the same range logic as String.substr + * (begin: first char index, length: char count to be included) + * if 'single' is true, any previous highlights will be removed prior to the new highlight range + * being added + * + * returns true if highlighting occurred, false otherwise + * + */ + public function highlightOffsetRange(begin:uint, length:uint, single:Boolean):Boolean{ + if (length > 0) { + return highlightRange(begin,begin+length,single); + } + return false; + } + + + /** + * + * This method uses the same range logic as String.substring + * (begin: first char index, end: char index after last) + * if 'single' is true, any previous highlights will be removed prior to the new highlight range + * being added + * + * returns true if highlighting occurred, false otherwise + * + * @royaleignorecoercion org.apache.royale.html.supportClasses.HighlightTextSpan + */ + public function highlightRange(begin:uint, end:uint, single:Boolean):Boolean{ + var tl:uint = textLength(); + if (end > tl) end = tl; + if (begin >= end) return false; + var l:uint = _highLights.length; + if (single) { + if (l) { + removeHighlights(); + l = 0; + } + } + + var insertAt:int = -1; + if (l) { + for (var i:int = 0;i<l;i++) { + var highlight:HighlightTextSpan = _highLights[i] as HighlightTextSpan; + if (highlight.intersectsRange(begin,end)) { + //for now, simple exclusions apply + // throw new Error('Highlight ranges must be exclusive') + + return false; + } + if (highlight.beginIndex >= end ) { + if (insertAt == -1) //we need the first location + insertAt = i; + } + } + } + if (insertAt == -1) insertAt = l; + var newHighLight:HighlightTextSpan = createHighlightSpan(begin,end); + if (insertAt == l) { + _highLights[insertAt] = newHighLight + } else { + _highLights.splice(insertAt,0,newHighLight); + } + + newHighLight.apply(); + return true; + } + + COMPILE::JS + private var docListening:Boolean; + + COMPILE::JS + private function enforceUnselectable(selection:Selection):void{ + + var selectionRange:Range = selection.getRangeAt(0); + var location:Node = selectionRange.endContainer; + var endOffset:uint = selectionRange.endOffset; + if (location != selectionRange.startContainer || selectionRange.startOffset != selectionRange.endOffset) { + selection.removeAllRanges(); + selectionRange = document.createRange(); + selectionRange.setStart(location,endOffset); + selectionRange.collapse(true); + selection.addRange(selectionRange); + } + } + + COMPILE::JS + protected function onLostFocus(event:Event):void{ + if (docListening){ + document.removeEventListener('selectionchange',onSelectEvent); + docListening = false; + } + } + + protected function onSelectEvent(event:Event):void{ + + COMPILE::JS{ + var selection:Selection; + if (!docListening) { + document.addEventListener('selectionchange',onSelectEvent); + docListening = true; + } + if (!_selectable) { + if (docListening){ + selection = window.getSelection(); + if (event.type == 'selectionchange') { + if (selection.baseNode != _textContainer && !_textContainer.contains(selection.baseNode)) { + document.removeEventListener('selectionchange',onSelectEvent); + docListening = false; + return; + } + } + } + enforceUnselectable(selection); + } + selection = window.getSelection(); + + } + + //trace(event) + } + + + private var _wordWrap:Boolean = true; + public function get wordWrap():Boolean { + return _wordWrap; + } + + public function set wordWrap(value:Boolean):void { + if (_wordWrap != value) { + _wordWrap = value; + COMPILE::JS{ + if (value) { + element.style.whiteSpace = 'pre-wrap'; + } else { + element.style.whiteSpace = 'pre'; + } + } + } + } + + + private var _selectable:Boolean = true; + public function get selectable():Boolean { + return _selectable; + } + + public function set selectable(value:Boolean):void { + if (_selectable != value) { + _selectable = value; + COMPILE::JS{ + if (value) { + _textContainer.style.userSelect = ''; + } else { + _textContainer.style.userSelect = 'none'; + } + } + } + } + + private var _editable:Boolean = true; + public function get editable():Boolean { + return _editable; + } + + public function set editable(value:Boolean):void { + if (_editable != value) { + _editable = value; + COMPILE::JS{ + _textContainer.contentEditable = String(value) ; + if (value) { + // element.addEventListener('input', onInput) + goog.events.listen(_textContainer, 'input', textChangeHandler); + } else { + // element.removeEventListener('input', onInput); + goog.events.unlisten(_textContainer, 'input', textChangeHandler); + } + } + } + } + + /** + * @royaleignorecoercion org.apache.royale.core.WrappedHTMLElement + * @royaleignorecoercion HTMLSpanElement + */ + COMPILE::JS + override protected function createElement():WrappedHTMLElement + { + addElementToWrapper(this,'div'); + _textContainer = document.createElement('span') as HTMLSpanElement; + _textContainer.onselectstart = onSelectEvent; + _textContainer.onblur = onLostFocus; + //for JS the parentRef is the parent span + _parentRef = _textContainer; + element.appendChild(_textContainer); + _textContainer.contentEditable = "true"; + goog.events.listen(_textContainer, 'input', textChangeHandler); + typeNames = 'SimpleTextHighlighter'; + return element; + } + + private var inSetter:Boolean; + + /** + * dispatch change event in response to a textChange event + * + * @langversion 3.0 + * @playerversion Flash 10.2 + * @playerversion AIR 2.6 + * @productversion Royale 0.9.0 + */ + public function textChangeHandler(event:Event):void + { + COMPILE::JS{ + if (!inSetter) + { + _lengthValid = false; + restoreIfNeeded(); + performAutoRemove(); + dispatchEvent(new Event(Event.CHANGE)); + } + } + + } + } +} diff --git a/frameworks/projects/Basic/src/main/royale/org/apache/royale/html/supportClasses/HighlightTextSpan.as b/frameworks/projects/Basic/src/main/royale/org/apache/royale/html/supportClasses/HighlightTextSpan.as new file mode 100644 index 0000000000..2143c757dd --- /dev/null +++ b/frameworks/projects/Basic/src/main/royale/org/apache/royale/html/supportClasses/HighlightTextSpan.as @@ -0,0 +1,256 @@ +//////////////////////////////////////////////////////////////////////////////// +// +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//////////////////////////////////////////////////////////////////////////////// +package org.apache.royale.html.supportClasses { + + +COMPILE::JS{ + import org.apache.royale.core.WrappedHTMLElement + } + + + /** + * @royalesuppressexport + */ + public class HighlightTextSpan{ + + private var _beginIndex: uint ; + private var _endIndex: uint ; + // private var _className:String = 'HighlightTextSpan'; + + COMPILE::JS + private static var _ranges:WeakMap = new WeakMap(); + + COMPILE::JS + protected var _parentSpan:HTMLSpanElement; + + COMPILE::JS + protected var _span:HTMLSpanElement; + + /** + * + * @royaleignorecoercion HTMLSpanElement + */ + public function HighlightTextSpan(beginIndex: uint=0, endIndex: uint=0, + parent:Object=null, className:String = null) { + _beginIndex = beginIndex; + _endIndex = endIndex; + COMPILE::JS{ + _parentSpan = parent as HTMLSpanElement + _text = _parentSpan.textContent.substring(beginIndex,endIndex); + } + if (className) { + this.className = className; + } + } + + + + public function isValid():Boolean{ + return _endIndex > _beginIndex; + } + + COMPILE::JS + private function getRange():Range{ + var range:Range = _ranges.get(_parentSpan); + if (!range) { + range = document.createRange(); + _ranges.set(_parentSpan, range); + range.selectNodeContents(_parentSpan); + } + return range; + } + + public function get beginIndex():uint { + return _beginIndex; + } + + public function get endIndex():uint { + return _endIndex; + } + + public function get textRepresentation():String{ + var ret:String; + COMPILE::JS{ + if (_span) { + ret = _span.textContent; + } + else if (_parentSpan) { + ret = _parentSpan.textContent.substring(_beginIndex,_endIndex); + } + } + + return ret; + } + + private var _text:String; + /** + * the original text at the time of construction + */ + public function get text():String{ + return _text; + } + + public function get isUnchanged():Boolean{ + return _text == textRepresentation && isPresent; + } + + public function get isPresent():Boolean{ + var absent:Boolean; + COMPILE::JS{ + absent = _span.parentNode != _parentSpan; + } + return !absent; + } + + + private var _className:String; + + public function get className():String { + return _className; + } + + public function set className(value:String):void { + COMPILE::JS{ + if (_span && _className) { + _span.classList.remove(_className); + } + } + _className = value; + COMPILE::JS{ + if (_span && value) { + _span.classList.add(value); + } + } + } + + public function unapply():void{ + COMPILE::JS{ + if (_span) { + if(_span.parentNode == _parentSpan) { + while(_span.firstChild) { + _parentSpan.insertBefore(_span.firstChild,_span) + } + _parentSpan.removeChild(_span); + } + //_span = null; + } + } + } + + /** + * + * @royaleignorecoercion HTMLSpanElement + * @royaleignorecoercion org.apache.royale.core.WrappedHTMLElement + */ + public function apply():void{ + COMPILE::JS{ + if (!_span) { + _span = document.createElement('span') as HTMLSpanElement; + element = (_span as WrappedHTMLElement) + element.royale_wrapper = this; + _span.className = 'HighlightTextSpan'; + var c:String = className; + if (c) { + _span.classList.add(c) + } + + var range:Range = getRange(); + var node:Node = _parentSpan.firstChild; + var beginNode:Node; + var beginOffset:uint; + var endNode:Node; + var endOffset:uint; + var offset:uint = _beginIndex; + var l:uint; + while(node) { + l = node.textContent.length; + if (l <= offset) { + offset -= l; + } else { + //no need to distinguish between nodes (nodeType == Node.TEXT_NODE etc), + //because we are only ever a sequence of text nodes or span nodes + if (!beginNode) { + beginNode = node; + beginOffset = offset; + offset = offset + (_endIndex - _beginIndex); + if (l < offset) { + offset -= l; + } else { + endNode = node; + endOffset = offset; + break; + } + } else { + endNode = node; + endOffset = offset; + break; + } + } + node = node.nextSibling; + } + range.setStart(beginNode,beginOffset); + range.setEnd(endNode,endOffset); + + range.surroundContents(_span); + } + + } + } + + + public function containsIndex(index:uint):Boolean{ + return index >=_beginIndex && index < _endIndex; + } + + public function equalsRange(beginIndex:uint, endIndex:uint):Boolean{ + if (beginIndex > endIndex) { + var temp:uint = endIndex; + endIndex = beginIndex; + beginIndex = temp; + } + return _beginIndex == beginIndex && _endIndex == endIndex; + } + + public function containsRange(beginIndex:uint, endIndex:uint):Boolean{ + if (beginIndex > endIndex) { + var temp:uint = endIndex; + endIndex = beginIndex; + beginIndex = temp; + } + return _beginIndex <= beginIndex && _endIndex >= endIndex; + } + + public function intersectsRange(beginIndex:uint, endIndex:uint):Boolean{ + if (beginIndex > endIndex) { + var temp:uint = endIndex; + endIndex = beginIndex; + beginIndex = temp; + } + return Math.max(_beginIndex, beginIndex) < Math.min(_endIndex, endIndex); + } + + + COMPILE::JS + public var element:WrappedHTMLElement; + + + + } +} +
