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