diff --git a/packages/troika-3d-text/src/facade/SelectionManagerFacade.js b/packages/troika-3d-text/src/facade/SelectionManagerFacade.js index 2b8b4224..bc9b863c 100644 --- a/packages/troika-3d-text/src/facade/SelectionManagerFacade.js +++ b/packages/troika-3d-text/src/facade/SelectionManagerFacade.js @@ -1,7 +1,6 @@ import { ListFacade } from 'troika-3d' import { Matrix4, Plane, Vector2, Vector3 } from 'three' -import { getCaretAtPoint, getSelectionRects } from 'troika-three-text' -import { invertMatrix4 } from 'troika-three-utils' +import { getCaretAtPoint } from 'troika-three-text' import SelectionRangeRect from './SelectionRangeRect.js' const THICKNESS = 0.25 //rect depth as percentage of height @@ -16,7 +15,7 @@ const noClip = Object.freeze([-Infinity, -Infinity, Infinity, Infinity]) * Manager facade for selection rects and user selection behavior */ class SelectionManagerFacade extends ListFacade { - constructor (parent, onSelectionChange) { + constructor(parent, onSelectionChange) { super(parent) const textMesh = parent.threeObject @@ -42,11 +41,17 @@ class SelectionManagerFacade extends ListFacade { } const onDragStart = e => { + if (e.which === 3) {//contextmenu + return false + } const textRenderInfo = textMesh.textRenderInfo if (textRenderInfo) { const textPos = textMesh.worldPositionToTextCoords(e.intersection.point, tempVec2) const caret = getCaretAtPoint(textRenderInfo, textPos.x, textPos.y) if (caret) { + textMesh.highlight.startIndex = caret.charIndex + textMesh.highlight.endIndex = caret.charIndex + textMesh.updateSelection(textRenderInfo) onSelectionChange(caret.charIndex, caret.charIndex) parent.addEventListener('drag', onDrag) parent.addEventListener('dragend', onDragEnd) @@ -56,6 +61,9 @@ class SelectionManagerFacade extends ListFacade { } const onDrag = e => { + if (e.which === 3) {//contextmenu + return false + } const textRenderInfo = textMesh.textRenderInfo if (e.ray && textRenderInfo) { // If it's hitting on the Text mesh, do an exact translation; otherwise raycast to an @@ -65,12 +73,14 @@ class SelectionManagerFacade extends ListFacade { if (ix && ix.object === textMesh && ix.point) { textPos = textMesh.worldPositionToTextCoords(ix.point, tempVec2) } else { - const ray = e.ray.clone().applyMatrix4(invertMatrix4(textMesh.matrixWorld, tempMat4)) - textPos = ray.intersectPlane(tempPlane.setComponents(0, 0, 1, 0), tempVec3) + // const ray = e.ray.clone().applyMatrix4(invertMatrix4(textMesh.matrixWorld, tempMat4)) + // textPos = ray.intersectPlane(tempPlane.setComponents(0, 0, 1, 0), tempVec3) } if (textPos) { const caret = getCaretAtPoint(textRenderInfo, textPos.x, textPos.y) if (caret) { + textMesh.highlight.endIndex = caret.charIndex + textMesh.updateSelection(textRenderInfo) onSelectionChange(this.selectionStart, caret.charIndex) } } @@ -83,18 +93,46 @@ class SelectionManagerFacade extends ListFacade { parent.removeEventListener('dragend', onDragEnd) } + const onMissClick = e => { + let target = e.target + do { + if (target.$facadeId === textMesh.parent.$facade.$facadeId) { + return + } + target = target.parent + } while (target !== null) + //clear selection + const textRenderInfo = textMesh.textRenderInfo + if (textRenderInfo) { + textMesh.highlight.startIndex = 0 + textMesh.highlight.endIndex = 0 + textMesh.updateSelection(textRenderInfo) + } + } + + //clear selection if missed click + parent.getSceneFacade().addEventListener('click', onMissClick) + parent.addEventListener('dragstart', onDragStart) parent.addEventListener('mousedown', onDragStart) + var canvas = parent.getSceneFacade().parent._threeRenderer.domElement; + canvas.addEventListener('contextmenu', (e) => { + textMesh._domElSelectedText.style.pointerEvents = 'auto' + textMesh.selectDomText() + window.setTimeout(() => { + textMesh._domElSelectedText.style.pointerEvents = 'none' + }, 50) + }) this._cleanupEvents = () => { onDragEnd() + parent.getSceneFacade().removeEventListener('click', onMissClick) parent.removeEventListener('dragstart', onDragStart) parent.removeEventListener('mousedown', onDragStart) } } afterUpdate() { - this.data = getSelectionRects(this.textRenderInfo, this.selectionStart, this.selectionEnd) super.afterUpdate() } @@ -106,7 +144,7 @@ class SelectionManagerFacade extends ListFacade { return this._clipRect } - destructor () { + destructor() { this._cleanupEvents() super.destructor() } diff --git a/packages/troika-3d-text/src/facade/Text3DFacade.js b/packages/troika-3d-text/src/facade/Text3DFacade.js index 79217a18..529a6b49 100644 --- a/packages/troika-3d-text/src/facade/Text3DFacade.js +++ b/packages/troika-3d-text/src/facade/Text3DFacade.js @@ -1,5 +1,5 @@ import { Object3DFacade } from 'troika-3d' -import { Text } from 'troika-three-text' +import { Text, makeDOMAcessible, makeSelectable } from 'troika-three-text' import SelectionManagerFacade from './SelectionManagerFacade.js' // Properties that will simply be forwarded to the TextMesh: @@ -19,6 +19,7 @@ const TEXT_MESH_PROPS = [ 'whiteSpace', 'material', 'color', + 'selectionColor', 'colorRanges', 'fillOpacity', 'outlineOpacity', @@ -36,7 +37,8 @@ const TEXT_MESH_PROPS = [ 'orientation', 'glyphGeometryDetail', 'sdfGlyphSize', - 'debugSDF' + 'debugSDF', + 'selectionMaterial' ] @@ -93,18 +95,18 @@ class Text3DFacade extends Object3DFacade { super.afterUpdate() - if (this.text !== this._prevText) { - // TODO mirror to DOM... this._domEl.textContent = this.text - // Clear selection when text changes - this.selectionStart = this.selectionEnd = -1 - this._prevText = this.text + if (this.accessible && !this.threeObject.isDOMAccessible) { + makeDOMAcessible(this.threeObject) + } + if (this.selectable && !this.threeObject.isSelectable) { + makeSelectable(this.threeObject) } this._updateSelection() } _updateSelection() { - const {selectable, selectionStart, selectionEnd} = this + const { selectable, selectionStart, selectionEnd } = this let selFacade = this._selectionFacade if (selectable !== this._selectable) { this._selectable = selectable diff --git a/packages/troika-examples/index.css b/packages/troika-examples/index.css index 9bf44170..92913303 100644 --- a/packages/troika-examples/index.css +++ b/packages/troika-examples/index.css @@ -4,6 +4,9 @@ html, body { color: #FFF; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 14px; + overflow: hidden; + width: 100vw; + height: 100vh; } .examples { diff --git a/packages/troika-examples/text/TextExample.jsx b/packages/troika-examples/text/TextExample.jsx index f0652910..e21b2e92 100644 --- a/packages/troika-examples/text/TextExample.jsx +++ b/packages/troika-examples/text/TextExample.jsx @@ -1,7 +1,7 @@ import React from 'react' import T from 'prop-types' import { Canvas3D, createDerivedMaterial, Object3DFacade } from 'troika-3d' -import {Text3DFacade, dumpSDFTextures} from 'troika-3d-text' +import { Text3DFacade, dumpSDFTextures } from 'troika-3d-text' import { MeshBasicMaterial, MeshStandardMaterial, @@ -11,7 +11,7 @@ import { Color, DoubleSide } from 'three' -import DatGui, {DatBoolean, DatSelect, DatNumber} from 'react-dat-gui' +import DatGui, { DatBoolean, DatSelect, DatNumber } from 'react-dat-gui' import { DatGuiFacade } from 'troika-3d-ui' import { ExampleConfigurator } from '../_shared/ExampleConfigurator.js' @@ -104,6 +104,7 @@ class TextExample extends React.Component { anchorX: 'center', anchorY: 'middle', color: 0xffffff, + selectionColor: 'white', fillOpacity: 1, strokeOpacity: 1, strokeColor: 0x808080, @@ -116,15 +117,20 @@ class TextExample extends React.Component { curveRadius: 0, fog: false, animTextColor: true, - animTilt: true, + animTilt: false, animRotate: false, material: 'MeshStandardMaterial', useTexture: false, shadows: false, - selectable: false, + selectable: true, + accessible: true, colorRanges: false, sdfGlyphSize: 6, - debugSDF: false + debugSDF: false, + camerax: 0, + cameray: 0, + cameraz: 2, + lookAt: { x: 0, y: 0, z: 0 } } this._onConfigUpdate = (newState) => { @@ -138,7 +144,7 @@ class TextExample extends React.Component { render() { let state = this.state - let {width, height} = this.props + let { width, height } = this.props let material = state.material if (state.useTexture) material += '+Texture' @@ -148,19 +154,20 @@ class TextExample extends React.Component {
{/* diff --git a/packages/troika-three-text/src/Text.js b/packages/troika-three-text/src/Text.js index cdc7419c..b90865b9 100644 --- a/packages/troika-three-text/src/Text.js +++ b/packages/troika-three-text/src/Text.js @@ -49,8 +49,10 @@ const Text = /*#__PURE__*/(() => { return mesh } - const syncStartEvent = {type: 'syncstart'} - const syncCompleteEvent = {type: 'synccomplete'} + const syncCompleteEvent = { type: 'synccomplete' } + const textChangeEvent = { type: 'textChange' } + const beforeRenderEvent = { type: 'beforerender' } + const afterRenderEvent = { type: 'afterrender' } const SYNCABLE_PROPS = [ 'font', @@ -75,7 +77,6 @@ const Text = /*#__PURE__*/(() => { 'color', 'depthOffset', 'clipRect', - 'curveRadius', 'orientation', 'glyphGeometryDetail' ) @@ -100,6 +101,7 @@ const Text = /*#__PURE__*/(() => { * The string of text to be rendered. */ this.text = '' + this.currentText = '' /** * @deprecated Use `anchorX` and `anchorY` instead @@ -395,15 +397,23 @@ const Text = /*#__PURE__*/(() => { if (this._needsSync) { this._needsSync = false + /* detect text change coming from the component */ + if (this.prevText !== this.text) { + this.currentText = this.text + this.dispatchEvent(textChangeEvent) + this.prevText = this.text + } + // If there's another sync still in progress, queue if (this._isSyncing) { (this._queuedSyncs || (this._queuedSyncs = [])).push(callback) } else { this._isSyncing = true - this.dispatchEvent(syncStartEvent) + + this.currentText = this.currentText ? this.currentText : this.text getTextRenderInfo({ - text: this.text, + text: this.currentText, font: this.font, fontSize: this.fontSize || 0.1, letterSpacing: this.letterSpacing || 0, @@ -462,12 +472,20 @@ const Text = /*#__PURE__*/(() => { onBeforeRender(renderer, scene, camera, geometry, material, group) { this.sync() + this.dispatchEvent(beforeRenderEvent) + // This may not always be a text material, e.g. if there's a scene.overrideMaterial present if (material.isTroikaTextMaterial) { this._prepareForRender(material) } } + onAfterRender(renderer, scene, camera) { + this.renderer = renderer + this.camera = camera + this.dispatchEvent(afterRenderEvent) + } + /** * Shortcut to dispose the geometry specific to this instance. * Note: we don't also dispose the derived material here because if anything else is @@ -511,7 +529,7 @@ const Text = /*#__PURE__*/(() => { let outlineMaterial = derivedMaterial._outlineMtl if (!outlineMaterial) { outlineMaterial = derivedMaterial._outlineMtl = Object.create(derivedMaterial, { - id: {value: derivedMaterial.id + 0.1} + id: { value: derivedMaterial.id + 0.1 } }) outlineMaterial.isTextOutlineMaterial = true outlineMaterial.depthWrite = false @@ -565,7 +583,7 @@ const Text = /*#__PURE__*/(() => { const uniforms = material.uniforms const textInfo = this.textRenderInfo if (textInfo) { - const {sdfTexture, blockBounds} = textInfo + const { sdfTexture, blockBounds } = textInfo uniforms.uTroikaSDFTexture.value = sdfTexture uniforms.uTroikaSDFTextureSize.value.set(sdfTexture.image.width, sdfTexture.image.height) uniforms.uTroikaSDFGlyphSize.value = textInfo.sdfGlyphSize @@ -583,7 +601,7 @@ const Text = /*#__PURE__*/(() => { let offsetY = 0 if (isOutline) { - let {outlineWidth, outlineOffsetX, outlineOffsetY, outlineBlur, outlineOpacity} = this + let { outlineWidth, outlineOffsetX, outlineOffsetY, outlineBlur, outlineOpacity } = this distanceOffset = this._parsePercent(outlineWidth) || 0 blurRadius = Math.max(0, this._parsePercent(outlineBlur) || 0) fillOpacity = outlineOpacity @@ -693,12 +711,12 @@ const Text = /*#__PURE__*/(() => { * TODO is there any reason to make this more granular, like within individual line or glyph rects? */ raycast(raycaster, intersects) { - const {textRenderInfo, curveRadius} = this + const { textRenderInfo, curveRadius } = this if (textRenderInfo) { const bounds = textRenderInfo.blockBounds const raycastMesh = curveRadius ? getCurvedRaycastMesh() : getFlatRaycastMesh() const geom = raycastMesh.geometry - const {position, uv} = geom.attributes + const { position, uv } = geom.attributes for (let i = 0; i < uv.count; i++) { let x = bounds[0] + (uv.getX(i) * (bounds[2] - bounds[0])) const y = bounds[1] + (uv.getY(i) * (bounds[3] - bounds[1])) @@ -740,6 +758,7 @@ const Text = /*#__PURE__*/(() => { } + // Create setters for properties that affect text layout: SYNCABLE_PROPS.forEach(prop => { const privateKey = '_private_' + prop diff --git a/packages/troika-three-text/src/TextHighlight.js b/packages/troika-three-text/src/TextHighlight.js new file mode 100644 index 00000000..adc2a6c4 --- /dev/null +++ b/packages/troika-three-text/src/TextHighlight.js @@ -0,0 +1,132 @@ +import { + Mesh, + MeshBasicMaterial, + Vector4, + Vector2, + BoxBufferGeometry, + Group +} from 'three' +import { createDerivedMaterial } from 'troika-three-utils' + +const defaultSelectionColor = 0xffffff + +const TextHighlight = /*#__PURE__*/(() => { + + /** + * @class Text + * + * A ThreeJS Mesh that renders a string of text on a plane in 3D space using signed distance + * fields (SDF). + */ + class TextHighlight extends Group { + constructor(parent) { + super(parent) + + this.color = 'white' + this.startIndex = 0 + this.endIndex = 0 + this.thickness = 0.25 + this.prevCurveRadius = 0 + this.childrenGeometry = new BoxBufferGeometry(1, 1, 0.1).translate(0.5, 0.5, 0.5) + this.childrenCurvedGeometry = new BoxBufferGeometry(1, 1, 0.1, 32).translate(0.5, 0.5, 0.5) + } + + /** + * visually update the rendering of the text selection in the renderer context + */ + highlightText() { + //todo manage rect update in a cleaner way. Currently we recreate everything everytime + //clean dispose of material no need to do it for geometry because we reuse the same + this.parent.selectionRectsMeshs.forEach((rect) => { + if (rect.parent) + rect.parent.remove(rect) + rect.material.dispose() + }) + this.parent.selectionRectsMeshs = [] + + this.parent.selectionRects.forEach((rect) => { + let material = createDerivedMaterial( + this.parent.selectionMaterial ? this.parent.selectionMaterial.clone() : new MeshBasicMaterial({ + color: this.parent.selectionColor ? this.parent.selectionColor : defaultSelectionColor, + transparent: true, + opacity: 0.3, + depthWrite: false + }), + { + uniforms: { + rect: { + value: new Vector4( + rect.left, + rect.top, + rect.right, + rect.bottom + ) + }, + depthAndCurveRadius: { + value: new Vector2( + (rect.top - rect.bottom) * this.thickness, + this.parent.curveRadius + ) + } + }, + vertexDefs: ` + uniform vec4 rect; + uniform vec2 depthAndCurveRadius; + `, + vertexTransform: ` + float depth = depthAndCurveRadius.x; + float rad = depthAndCurveRadius.y; + position.x = mix(rect.x, rect.z, position.x); + position.y = mix(rect.w, rect.y, position.y); + position.z = mix(-depth * 0.5, depth * 0.5, position.z); + if (rad != 0.0) { + float angle = position.x / rad; + position.xz = vec2(sin(angle) * (rad - position.z), rad - cos(angle) * (rad - position.z)); + // TODO fix normals: normal.xz = vec2(sin(angle), cos(angle)); + } + ` + } + ) + material.instanceUniforms = ['rect', 'depthAndCurveRadius', 'diffuse'] + let selectRect = new Mesh( + this.parent.curveRadius === 0 ? this.childrenGeometry : this.childrenCurvedGeometry, + material + // new MeshBasicMaterial({color: 0xffffff,side: DoubleSide,transparent: true, opacity:0.5}) + ) + this.parent.selectionRectsMeshs.unshift(selectRect) + this.parent.add(selectRect) + }) + this.parent.updateWorldMatrix(false, true) + } + + updateHighlightTextUniforms() { + if ( + this.prevCurveRadius === 0 && this.parent.curveRadius !== 0 + || + this.prevCurveRadius !== 0 && this.parent.curveRadius === 0 + ) { + this.prevCurveRadius = this.parent.curveRadius + //update geometry + this.parent.selectionRectsMeshs.forEach((rect) => { + rect.geometry = this.parent.curveRadius === 0 ? this.childrenGeometry : this.childrenCurvedGeometry + }) + } + this.parent.selectionRectsMeshs.forEach((rect) => { + rect.material.uniforms.depthAndCurveRadius.value.y = this.parent.curveRadius + if (this.parent.selectionColor != rect.material.color) { + //faster to check fo color change or to set needsUpdate true each time ? + //todo + rect.material.color.set(this.parent.selectionColor) + rect.material.needsUpdate = true + } + }) + } + + } + + return TextHighlight +})() + +export { + TextHighlight +} diff --git a/packages/troika-three-text/src/index.js b/packages/troika-three-text/src/index.js index 420ec596..6358daaf 100644 --- a/packages/troika-three-text/src/index.js +++ b/packages/troika-three-text/src/index.js @@ -5,3 +5,6 @@ export { Text } from './Text.js' export { GlyphsGeometry } from './GlyphsGeometry.js' export { createTextDerivedMaterial } from './TextDerivedMaterial.js' export { getCaretAtPoint, getSelectionRects } from './selectionUtils.js' +export { makeDOMAcessible } from './makeDOMAcessible.js' +export { makeSelectable } from './makeSelectable.js' +export { TextHighlight } from './TextHighlight.js' diff --git a/packages/troika-three-text/src/makeDOMAcessible.js b/packages/troika-three-text/src/makeDOMAcessible.js new file mode 100644 index 00000000..5ada5554 --- /dev/null +++ b/packages/troika-three-text/src/makeDOMAcessible.js @@ -0,0 +1,120 @@ +import { textRectToCssMatrix } from './selectionUtils' + + +const domOverlayBaseStyles = ` +position:fixed; +top:0; +left:0; +opacity:0; +overflow:hidden; +margin:0; +pointer-events:none; +width:10px; +height:10px; +transform-origin:0 0; +font-size:10px; +line-height: 10px; +user-select: all; +` + +const domSRoutline = ` +line-break: anywhere; +line-height: 0px; +display: flex; +align-items: center; +` + +const makeDOMAcessible = (textInstance, options = {}) => { + console.log(options) + + const _options = Object.assign({ + domContainer: document.documentElement, + tagName: 'p', + observeMutation: true + }, options); + + textInstance._domElText = document.createElement(_options.tagName) + + _options.domContainer.appendChild(textInstance._domElText) + textInstance._domElText.style = domOverlayBaseStyles + domSRoutline + textInstance.isDOMAccessible = true + + /** + * When a change occurs on the overlaying HTML, it reflect it in the renderer context + */ + textInstance.mutationCallback = function (mutationsList, observer) { + this.currentHTML = this._domElText.innerHTML + this.currentText = this.currentHTML.replace(/<(?!br\s*\/?)[^>]+>/g, '').replace(//gi, "\n"); + this._needsSync = true; + this.sync() + } + + if (_options.observeMutation) { + console.log(_options.observeMutation) + /** + * Start watching change on the overlaying HTML such as browser dom translation in order to reflect it in the renderer context + */ + textInstance.startObservingMutation = function () { + textInstance.observer = new MutationObserver(textInstance.mutationCallback.bind(textInstance)); + textInstance.observer.observe(textInstance._domElText, { attributes: false, childList: true, subtree: false }); + } + textInstance.startObservingMutation() + } + + textInstance.prevText = '' + textInstance.currentText = '' + textInstance.prevHTML = '' + textInstance.currentHTML = '' + textInstance.prevCurveRadius = 0 + textInstance.pauseDomSync = false + + textInstance.syncDOM = function () { + this.currentText = this.text + this.prevText = this.text + this.currentHTML = this.text.replace(/(?:\r\n|\r|\n)/g, '
') + if (_options.observeMutation) + this.observer.disconnect() + this._domElText.innerHTML = this.currentHTML; + this.prevHTML = this.currentHTML + if (_options.observeMutation) + this.observer.observe(this._domElText, { attributes: false, childList: true, subtree: false }); + } + + textInstance.addEventListener('textChange', textInstance.syncDOM) + + textInstance.syncDOM() + + /** + * update the position of the overlaying HTML that contain all the text that need to be accessible to screen readers + */ + textInstance.updateDomPosition = function (renderer, camera) { + if (!this.pauseDomSync) { + const { min, max } = this.geometry.boundingBox + this._domElText.style.transform = textRectToCssMatrix(min.x, min.y, max.x, max.y, max.z, renderer, camera, this.matrixWorld) + } + } + + textInstance.addEventListener('afterrender', function () { + const renderer = this.renderer + const camera = this.camera + this.updateDomPosition(renderer, camera) + }) + + textInstance.pause = function () { + this.pauseDomSync = true + } + + textInstance.resume = function () { + this.pauseDomSync = false + } + + textInstance.destroy = function () { + this.observer.disconnect() + this._domElText.remove() + } + +} + +export { + makeDOMAcessible +} diff --git a/packages/troika-three-text/src/makeSelectable.js b/packages/troika-three-text/src/makeSelectable.js new file mode 100644 index 00000000..9b671a89 --- /dev/null +++ b/packages/troika-three-text/src/makeSelectable.js @@ -0,0 +1,126 @@ +import { getSelectionRects, textRectToCssMatrix } from './selectionUtils' +import { TextHighlight } from './TextHighlight.js' + +const domOverlayBaseStyles = ` +position:fixed; +top:0; +left:0; +opacity:0; +overflow:hidden; +margin:0; +pointer-events:none; +width:10px; +height:10px; +transform-origin:0 0; +font-size:10px; +line-height: 10px; +user-select: all; +` + +const makeSelectable = (textInstance, options = {}) => { + + const _options = Object.assign({ + domContainer: document.documentElement + }, options); + + textInstance._domElSelectedText = document.createElement('p') + textInstance.selectedText = null; + + _options.domContainer.appendChild(textInstance._domElSelectedText) + + textInstance._domElSelectedText.setAttribute('aria-hidden', 'true') + textInstance._domElSelectedText.style = domOverlayBaseStyles + + textInstance.selectionRects = [] + textInstance.selectionRectsMeshs = [] + + textInstance.isSelectable = true + + /** + * @member {THREE.Material} selectionMaterial + * Defines a _base_ material to be used when rendering the text selection. This material will be + * automatically replaced with a material derived from it, that adds shader code to manage + * curved text. + * By default it will derive from a simple white MeshBasicMaterial with alpha of 0.3, but you can use any + * of the other mesh materials to gain other features like lighting, texture maps, etc. + * + * Also see the `selectionColor` shortcut property. + */ + textInstance.selectionMaterial = null + + /** + * @member {string|number|THREE.Color} selectionColor + * This is a shortcut for setting the `color` of the text selection's material. You can use this + * if you don't want to specify a whole custom `material`. Also, if you do use a custom + * `material`, this color will only be used for this particuar Text instance, even if + * that same material instance is shared across multiple Text objects. + */ + textInstance.selectionColor = null + + textInstance.highlight = new TextHighlight() + textInstance.add(textInstance.highlight) + + /** + * update the selection visually and everything related to copy /paste + */ + textInstance.updateSelection = function (textRenderInfo) { + this.selectedText = this.text.substring(this.highlight.startIndex, this.highlight.endIndex) + this.selectionRects = getSelectionRects(textRenderInfo, this.highlight.startIndex, this.highlight.endIndex) + this._domElSelectedText.textContent = this.selectedText + this.highlight.highlightText() + this.selectDomText() + } + + /** + * Select the text contened in _domElSelectedText in order for it to reflect what's currently selected in the Text + */ + textInstance.selectDomText = function () { + const sel = document.getSelection() + sel.removeAllRanges() + const range = document.createRange() + range.selectNodeContents(this._domElSelectedText); //sets Range + sel.removeAllRanges(); //remove all ranges from selection + sel.addRange(range); + } + + /** + * update the position of the overlaying HTML that contain + * the selected text in order for it to be acessible through context menu copy + */ + textInstance.updateSelectedDomPosition = function (renderer, camera) { + const rects = this.selectionRects + const el = this._domElSelectedText + if (rects && rects.length) { + // Find local space rect containing all selection rects + // TODO can we wrap this even tighter to multiline selections where top/bottom lines are partially selected? + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity + for (let i = 0; i < rects.length; i++) { + minX = Math.min(minX, rects[i].left) + minY = Math.min(minY, rects[i].bottom) + maxX = Math.max(maxX, rects[i].right) + maxY = Math.max(maxY, rects[i].top) + } + + const z = this.geometry.boundingBox.max.z + el.style.transform = textRectToCssMatrix(minX, minY, maxX, maxY, z, renderer, camera, this.matrixWorld) + el.style.display = 'block' + } else { + el.style.display = 'none' + } + } + + textInstance.addEventListener('beforerender', function () { + this.highlight.updateHighlightTextUniforms() + }) + + textInstance.addEventListener('afterrender', function () { + const renderer = this.renderer + const camera = this.camera + this.updateSelectedDomPosition(renderer, camera) + }) + +} + +export { + makeSelectable +} diff --git a/packages/troika-three-text/src/selectionUtils.js b/packages/troika-three-text/src/selectionUtils.js index 8e00bf3c..3b004213 100644 --- a/packages/troika-three-text/src/selectionUtils.js +++ b/packages/troika-three-text/src/selectionUtils.js @@ -1,3 +1,7 @@ +import { + Matrix4, + Vector3, +} from 'three' //=== Utility functions for dealing with carets and selection ranges ===// /** @@ -9,6 +13,10 @@ * character; the caret will be for the position _before_ that character. */ +const tempMat4a = new Matrix4() +const tempMat4b = new Matrix4() +const tempVec3 = new Vector3() + /** * Given a local x/y coordinate in the text block plane, find the nearest caret position. * @param {TroikaTextRenderInfo} textRenderInfo - a result object from TextBuilder#getTextRenderInfo @@ -18,7 +26,7 @@ */ export function getCaretAtPoint(textRenderInfo, x, y) { let closestCaret = null - const {caretHeight} = textRenderInfo + const { caretHeight } = textRenderInfo const caretsByRow = groupCaretsByRow(textRenderInfo) // Find nearest row by y first @@ -58,7 +66,7 @@ export function getSelectionRects(textRenderInfo, start, end) { return prevResult.rects } - const {caretPositions, caretHeight} = textRenderInfo + const { caretPositions, caretHeight } = textRenderInfo // Normalize if (end < start) { @@ -76,35 +84,18 @@ export function getSelectionRects(textRenderInfo, start, end) { for (let i = start; i < end; i++) { const x1 = caretPositions[i * 3] const x2 = caretPositions[i * 3 + 1] - const left = Math.min(x1, x2) - const right = Math.max(x1, x2) - const bottom = caretPositions[i * 3 + 2] - if (!currentRect || bottom !== currentRect.bottom || left > currentRect.right || right < currentRect.left) { - currentRect = { - left: Infinity, - right: -Infinity, - bottom: bottom, - top: bottom + caretHeight - } - rects.push(currentRect) - } - currentRect.left = Math.min(left, currentRect.left) - currentRect.right = Math.max(right, currentRect.right) - } - - // Merge any overlapping rects, e.g. those formed by adjacent bidi runs - rects.sort((a, b) => b.bottom - a.bottom || a.left - b.left) - for (let i = rects.length - 1; i-- > 0;) { - const rectA = rects[i] - const rectB = rects[i + 1] - if (rectA.bottom === rectB.bottom && rectA.left <= rectB.right && rectA.right >= rectB.left) { - rectB.left = Math.min(rectB.left, rectA.left) - rectB.right = Math.max(rectB.right, rectA.right) - rects.splice(i, 1) + const y = caretPositions[i * 3 + 2] + let row = rows.get(y) + if (!row) { + row = { left: Math.min(x1, x2), right: Math.max(x1, x2), bottom: y, top: y + caretHeight } + rows.set(y, row) + } else { + row.left = Math.min(row.left, x1, x2) + row.right = Math.max(row.right, x2, x2) } } - _rectsCache.set(textRenderInfo, {start, end, rects}) + _rectsCache.set(textRenderInfo, { start, end, rects }) } return rects } @@ -115,7 +106,7 @@ function groupCaretsByRow(textRenderInfo) { // textRenderInfo is frozen so it's safe to cache based on it let caretsByRow = _caretsByRowCache.get(textRenderInfo) if (!caretsByRow) { - const {caretPositions, caretHeight} = textRenderInfo + const { caretPositions, caretHeight } = textRenderInfo caretsByRow = new Map() for (let i = 0; i < caretPositions.length; i += 3) { const rowY = caretPositions[i + 2] @@ -143,3 +134,33 @@ function groupCaretsByRow(textRenderInfo) { _caretsByRowCache.set(textRenderInfo, caretsByRow) return caretsByRow } + +/** + * Given a rect in local text coordinates, build a CSS matrix3d that will transform + * a 10x10 DOM element to line up exactly with that rect on the screen. + * @private + */ +export function textRectToCssMatrix(minX, minY, maxX, maxY, z, renderer, camera, matrixWorld) { + const canvasRect = renderer.domElement.getBoundingClientRect() + + // element dimensions to geometry dimensions (flipping the y) + tempMat4a.makeScale((maxX - minX) / 10, (minY - maxY) / 10, 1) + .setPosition(tempVec3.set(minX, maxY, z)) + + // geometry to world + tempMat4a.premultiply(matrixWorld) + + // world to camera + tempMat4a.premultiply(camera.matrixWorldInverse) + + // camera to projection + tempMat4a.premultiply(camera.projectionMatrix) + + // projection coords (-1 to 1) to screen pixels + tempMat4a.premultiply( + tempMat4b.makeScale(canvasRect.width / 2, -canvasRect.height / 2, 1) + .setPosition(canvasRect.left + canvasRect.width / 2, canvasRect.top + canvasRect.height / 2, 0) + ) + + return `matrix3d(${tempMat4a.elements.join(',')})` +} \ No newline at end of file