| 
 | 1 | +import _ from 'lodash'  | 
 | 2 | +import { isDocument } from './document'  | 
 | 3 | + | 
 | 4 | +export const detectVisibility = ($el: any) => {  | 
 | 5 | +  const list = extractTransformInfoFromElements($el)  | 
 | 6 | + | 
 | 7 | +  if (existsInvisibleBackface(list)) {  | 
 | 8 | +    return elIsBackface(list) ? 'backface' : 'visible'  | 
 | 9 | +  }  | 
 | 10 | + | 
 | 11 | +  return elIsTransformedToZero(list) ? 'transformed' : 'visible'  | 
 | 12 | +}  | 
 | 13 | + | 
 | 14 | +type BackfaceVisibility = 'hidden' | 'visible'  | 
 | 15 | +type TransformStyle = 'flat' | 'preserve-3d'  | 
 | 16 | +type Matrix2D = [  | 
 | 17 | +  number, number, number,  | 
 | 18 | +  number, number, number,  | 
 | 19 | +]  | 
 | 20 | +type Matrix3D = [  | 
 | 21 | +  number, number, number, number,  | 
 | 22 | +  number, number, number, number,  | 
 | 23 | +  number, number, number, number,  | 
 | 24 | +  number, number, number, number,  | 
 | 25 | +]  | 
 | 26 | + | 
 | 27 | +type Vector3 = [number, number, number]  | 
 | 28 | + | 
 | 29 | +interface TransformInfo {  | 
 | 30 | +  backfaceVisibility: BackfaceVisibility  | 
 | 31 | +  transformStyle: TransformStyle  | 
 | 32 | +  transform: string  | 
 | 33 | +}  | 
 | 34 | + | 
 | 35 | +const extractTransformInfoFromElements = ($el: any, list: TransformInfo[] = []): TransformInfo[] => {  | 
 | 36 | +  list.push(extractTransformInfo($el))  | 
 | 37 | + | 
 | 38 | +  const $parent = $el.parent()  | 
 | 39 | + | 
 | 40 | +  if (!$parent.length || isDocument($parent)) {  | 
 | 41 | +    return list  | 
 | 42 | +  }  | 
 | 43 | + | 
 | 44 | +  return extractTransformInfoFromElements($parent, list)  | 
 | 45 | +}  | 
 | 46 | + | 
 | 47 | +const extractTransformInfo = ($el): TransformInfo => {  | 
 | 48 | +  const el = $el[0]  | 
 | 49 | +  const style = getComputedStyle(el)  | 
 | 50 | + | 
 | 51 | +  return {  | 
 | 52 | +    backfaceVisibility: style.getPropertyValue('backface-visibility') as BackfaceVisibility,  | 
 | 53 | +    transformStyle: style.getPropertyValue('transform-style') as TransformStyle,  | 
 | 54 | +    transform: style.getPropertyValue('transform'),  | 
 | 55 | +  }  | 
 | 56 | +}  | 
 | 57 | + | 
 | 58 | +const existsInvisibleBackface = (list: TransformInfo[]) => {  | 
 | 59 | +  return !!_.find(list, { backfaceVisibility: 'hidden' })  | 
 | 60 | +}  | 
 | 61 | + | 
 | 62 | +const numberRegex = /-?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/g  | 
 | 63 | +const defaultNormal: Vector3 = [0, 0, 1]  | 
 | 64 | +const viewVector: Vector3 = [0, 0, -1]  | 
 | 65 | +const identityMatrix3D: Matrix3D = [  | 
 | 66 | +  1, 0, 0, 0,  | 
 | 67 | +  0, 1, 0, 0,  | 
 | 68 | +  0, 0, 1, 0,  | 
 | 69 | +  0, 0, 0, 1,  | 
 | 70 | +]  | 
 | 71 | + | 
 | 72 | +// It became 1e-5 from 1e-10. Because 30deg + 30deg + 30deg is 6.0568e-7 and it caused a false negative.  | 
 | 73 | +const TINY_NUMBER = 1e-5  | 
 | 74 | + | 
 | 75 | +const nextPreserve3d = (i: number, list: TransformInfo[]) => {  | 
 | 76 | +  return i + 1 < list.length &&  | 
 | 77 | +  list[i + 1].transformStyle === 'preserve-3d'  | 
 | 78 | +}  | 
 | 79 | + | 
 | 80 | +const finalNormal = (startIndex: number, list: TransformInfo[]) => {  | 
 | 81 | +  let i = startIndex  | 
 | 82 | +  let normal = findNormal(parseMatrix3D(list[i].transform))  | 
 | 83 | + | 
 | 84 | +  while (nextPreserve3d(i, list)) {  | 
 | 85 | +    i++  | 
 | 86 | +    normal = findNormal(parseMatrix3D(list[i].transform), normal)  | 
 | 87 | +  }  | 
 | 88 | + | 
 | 89 | +  return normal  | 
 | 90 | +}  | 
 | 91 | + | 
 | 92 | +const elIsBackface = (list: TransformInfo[]) => {  | 
 | 93 | +  // When the direct parent of the target has style, preserve-3d  | 
 | 94 | +  if (list.length > 1 && list[1].transformStyle === 'preserve-3d') {  | 
 | 95 | +    // When the target is backface-invisible a2-1-1 ~ a2-1-4  | 
 | 96 | +    if (list[0].backfaceVisibility === 'hidden') {  | 
 | 97 | +      let normal = finalNormal(0, list)  | 
 | 98 | + | 
 | 99 | +      if (checkBackface(normal)) {  | 
 | 100 | +        return true  | 
 | 101 | +      }  | 
 | 102 | +    } else {  | 
 | 103 | +      // When the direct parent of the target is backface-invisible  | 
 | 104 | +      if (list[1].backfaceVisibility === 'hidden') {  | 
 | 105 | +        // If it is not none, it is visible. Check a2-3-1  | 
 | 106 | +        if (list[0].transform === 'none') {  | 
 | 107 | +          let normal = finalNormal(1, list)  | 
 | 108 | + | 
 | 109 | +          if (checkBackface(normal)) {  | 
 | 110 | +            return true  | 
 | 111 | +          }  | 
 | 112 | +        }  | 
 | 113 | +      }  | 
 | 114 | + | 
 | 115 | +      // Check 90deg a2-2-3, a2-2-4.  | 
 | 116 | +      let normal = finalNormal(0, list)  | 
 | 117 | + | 
 | 118 | +      return isElementOrthogonalWithView(normal)  | 
 | 119 | +    }  | 
 | 120 | +  } else {  | 
 | 121 | +    for (let i = 0; i < list.length; i++) {  | 
 | 122 | +      // Ignore preserve-3d when it is not a direct parent.  | 
 | 123 | +      // Why? -> https://github.com/cypress-io/cypress/pull/5916  | 
 | 124 | +      if (i > 0 && list[i].transformStyle === 'preserve-3d') {  | 
 | 125 | +        continue  | 
 | 126 | +      }  | 
 | 127 | + | 
 | 128 | +      if (list[i].backfaceVisibility === 'hidden' && list[i].transform.startsWith('matrix3d')) {  | 
 | 129 | +        let normal = findNormal(parseMatrix3D(list[i].transform))  | 
 | 130 | + | 
 | 131 | +        if (checkBackface(normal)) {  | 
 | 132 | +          return true  | 
 | 133 | +        }  | 
 | 134 | +      }  | 
 | 135 | +    }  | 
 | 136 | +  }  | 
 | 137 | + | 
 | 138 | +  return false  | 
 | 139 | +}  | 
 | 140 | + | 
 | 141 | +// This function uses a simplified version of backface culling.  | 
 | 142 | +// https://en.wikipedia.org/wiki/Back-face_culling  | 
 | 143 | +//  | 
 | 144 | +// We defined view vector, (0, 0, -1), - eye to screen.  | 
 | 145 | +// and default normal vector of an element, (0, 0, 1)  | 
 | 146 | +// When dot product of them are >= 0, item is visible.  | 
 | 147 | +const checkBackface = (normal: Vector3) => {  | 
 | 148 | +  // Simplified dot product.  | 
 | 149 | +  // viewVector[0] and viewVector[1] are always 0. So, they're ignored.  | 
 | 150 | +  let dot = viewVector[2] * normal[2]  | 
 | 151 | + | 
 | 152 | +  // Because of the floating point number rounding error,  | 
 | 153 | +  // cos(90deg) isn't 0. It's 6.12323e-17.  | 
 | 154 | +  // And it sometimes causes errors when dot product value is something like -6.12323e-17.  | 
 | 155 | +  // So, we're setting the dot product result to 0 when its absolute value is less than SMALL_NUMBER(10^-10).  | 
 | 156 | +  if (Math.abs(dot) < TINY_NUMBER) {  | 
 | 157 | +    dot = 0  | 
 | 158 | +  }  | 
 | 159 | + | 
 | 160 | +  return dot >= 0  | 
 | 161 | +}  | 
 | 162 | + | 
 | 163 | +const parseMatrix3D = (transform: string): Matrix3D => {  | 
 | 164 | +  if (transform === 'none') {  | 
 | 165 | +    return identityMatrix3D  | 
 | 166 | +  }  | 
 | 167 | + | 
 | 168 | +  if (transform.startsWith('matrix3d')) {  | 
 | 169 | +    const matrix: Matrix3D = transform.substring(8).match(numberRegex)!.map((n) => {  | 
 | 170 | +      return parseFloat(n)  | 
 | 171 | +    }) as Matrix3D  | 
 | 172 | + | 
 | 173 | +    return matrix  | 
 | 174 | +  }  | 
 | 175 | + | 
 | 176 | +  return toMatrix3d(transform.match(numberRegex)!.map((n) => parseFloat(n)) as Matrix2D)  | 
 | 177 | +}  | 
 | 178 | + | 
 | 179 | +const parseMatrix2D = (transform: string): Matrix2D => {  | 
 | 180 | +  return transform.match(numberRegex)!.map((n) => parseFloat(n)) as Matrix2D  | 
 | 181 | +}  | 
 | 182 | + | 
 | 183 | +const findNormal = (matrix: Matrix3D, normal: Vector3 = defaultNormal): Vector3 => {  | 
 | 184 | +  const m = matrix // alias for shorter formula  | 
 | 185 | +  const v = normal // alias for shorter formula  | 
 | 186 | +  const computedNormal: Vector3 = [  | 
 | 187 | +    m[0] * v[0] + m[4] * v[1] + m[8] * v[2],  | 
 | 188 | +    m[1] * v[0] + m[5] * v[1] + m[9] * v[2],  | 
 | 189 | +    m[2] * v[0] + m[6] * v[1] + m[10] * v[2],  | 
 | 190 | +  ]  | 
 | 191 | + | 
 | 192 | +  return toUnitVector(computedNormal)  | 
 | 193 | +}  | 
 | 194 | + | 
 | 195 | +const toMatrix3d = (m2d: Matrix2D): Matrix3D => {  | 
 | 196 | +  return [  | 
 | 197 | +    m2d[0], m2d[1], 0, 0,  | 
 | 198 | +    m2d[2], m2d[3], 0, 0,  | 
 | 199 | +    0, 0, 1, 0,  | 
 | 200 | +    m2d[4], m2d[5], 0, 1,  | 
 | 201 | +  ]  | 
 | 202 | +}  | 
 | 203 | + | 
 | 204 | +const toUnitVector = (v: Vector3): Vector3 => {  | 
 | 205 | +  const length = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2])  | 
 | 206 | + | 
 | 207 | +  return [v[0] / length, v[1] / length, v[2] / length]  | 
 | 208 | +}  | 
 | 209 | + | 
 | 210 | +// This function checks 2 things that can happen: scale and rotate to 0 in width or height.  | 
 | 211 | +const elIsTransformedToZero = (list: TransformInfo[]) => {  | 
 | 212 | +  if (list[1].transformStyle === 'preserve-3d') {  | 
 | 213 | +    const normal = finalNormal(0, list)  | 
 | 214 | + | 
 | 215 | +    return isElementOrthogonalWithView(normal)  | 
 | 216 | +  }  | 
 | 217 | + | 
 | 218 | +  return !!_.find(list, (info) => isTransformedToZero(info))  | 
 | 219 | +}  | 
 | 220 | + | 
 | 221 | +const isTransformedToZero = ({ transform }: TransformInfo) => {  | 
 | 222 | +  if (transform === 'none') {  | 
 | 223 | +    return false  | 
 | 224 | +  }  | 
 | 225 | + | 
 | 226 | +  // To understand how this part works,  | 
 | 227 | +  // you need to understand tranformation matrix first.  | 
 | 228 | +  // Matrix is hard to explain with only text. So, check these articles.  | 
 | 229 | +  //  | 
 | 230 | +  // https://www.useragentman.com/blog/2011/01/07/css3-matrix-transform-for-the-mathematically-challenged/  | 
 | 231 | +  // https://en.wikipedia.org/wiki/Rotation_matrix#In_three_dimensions  | 
 | 232 | +  //  | 
 | 233 | +  if (transform.startsWith('matrix3d')) {  | 
 | 234 | +    const matrix3d = parseMatrix3D(transform)  | 
 | 235 | + | 
 | 236 | +    if (is3DMatrixScaledTo0(matrix3d)) {  | 
 | 237 | +      return true  | 
 | 238 | +    }  | 
 | 239 | + | 
 | 240 | +    const normal = findNormal(matrix3d)  | 
 | 241 | + | 
 | 242 | +    return isElementOrthogonalWithView(normal)  | 
 | 243 | +  }  | 
 | 244 | + | 
 | 245 | +  const m = parseMatrix2D(transform)  | 
 | 246 | + | 
 | 247 | +  if (is2DMatrixScaledTo0(m)) {  | 
 | 248 | +    return true  | 
 | 249 | +  }  | 
 | 250 | + | 
 | 251 | +  return false  | 
 | 252 | +}  | 
 | 253 | + | 
 | 254 | +const is3DMatrixScaledTo0 = (m3d: Matrix3D) => {  | 
 | 255 | +  const xAxisScaledTo0 = m3d[0] === 0 && m3d[4] === 0 && m3d[8] === 0  | 
 | 256 | +  const yAxisScaledTo0 = m3d[1] === 0 && m3d[5] === 0 && m3d[9] === 0  | 
 | 257 | +  const zAxisScaledTo0 = m3d[2] === 0 && m3d[6] === 0 && m3d[10] === 0  | 
 | 258 | + | 
 | 259 | +  if (xAxisScaledTo0 || yAxisScaledTo0 || zAxisScaledTo0) {  | 
 | 260 | +    return true  | 
 | 261 | +  }  | 
 | 262 | + | 
 | 263 | +  return false  | 
 | 264 | +}  | 
 | 265 | + | 
 | 266 | +const is2DMatrixScaledTo0 = (m: Matrix2D) => {  | 
 | 267 | +  const xAxisScaledTo0 = m[0] === 0 && m[2] === 0  | 
 | 268 | +  const yAxisScaledTo0 = m[1] === 0 && m[3] === 0  | 
 | 269 | + | 
 | 270 | +  if (xAxisScaledTo0 || yAxisScaledTo0) {  | 
 | 271 | +    return true  | 
 | 272 | +  }  | 
 | 273 | + | 
 | 274 | +  return false  | 
 | 275 | +}  | 
 | 276 | + | 
 | 277 | +const isElementOrthogonalWithView = (normal: Vector3) => {  | 
 | 278 | +  // Simplified dot product.  | 
 | 279 | +  // [0] and [1] are always 0  | 
 | 280 | +  const dot = viewVector[2] * normal[2]  | 
 | 281 | + | 
 | 282 | +  return Math.abs(dot) < TINY_NUMBER  | 
 | 283 | +}  | 
0 commit comments