diff --git a/package-lock.json b/package-lock.json index 6ab6306..148ab3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5609,12 +5609,29 @@ "integrity": "sha512-PijRCG/K3s3w1We6ynUKdxEc5AcuuH3NBmMDP8uvKVp6X43UY7NQlTzczakXP3DJR0F4dfNQIGjU2cUeRYs2AA==", "dev": true }, + "@types/prop-types": { + "version": "15.7.4", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz", + "integrity": "sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==", + "dev": true + }, "@types/q": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.4.tgz", "integrity": "sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug==", "dev": true }, + "@types/react": { + "version": "17.0.20", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.20.tgz", + "integrity": "sha512-wWZrPlihslrPpcKyCSlmIlruakxr57/buQN1RjlIeaaTWDLtJkTtRW429MoQJergvVKc4IWBpRhWw7YNh/7GVA==", + "dev": true, + "requires": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, "@types/resolve": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", @@ -5624,6 +5641,12 @@ "@types/node": "*" } }, + "@types/scheduler": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", + "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", + "dev": true + }, "@types/stack-utils": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-1.0.1.tgz", @@ -8252,6 +8275,12 @@ "cssom": "0.3.x" } }, + "csstype": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.8.tgz", + "integrity": "sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw==", + "dev": true + }, "dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -19929,6 +19958,12 @@ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "dev": true }, + "resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "dev": true + }, "resolve": { "version": "1.20.0", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz", @@ -21719,9 +21754,9 @@ } }, "typescript": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.2.4.tgz", - "integrity": "sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg==", + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.4.2.tgz", + "integrity": "sha512-gzP+t5W4hdy4c+68bfcv0t400HVJMMd2+H9B7gae1nQlBzCqvrXX+6GL/b3GAgyTH966pzrZ70/fRjwAtZksSQ==", "dev": true }, "unbox-primitive": { diff --git a/package.json b/package.json index 4147479..7cb5bca 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "description": "Simple multiline ellipsis component for React.JS", "main": "lib/index.js", "module": "lib/index.modern.js", + "types": "lib/index.d.ts", "files": [ "lib" ], @@ -12,7 +13,7 @@ "lint:fix": "standard --fix", "prebuild": "rm -rf lib; mkdir -p lib", "prepare": "npm run build", - "build": "microbundle -f modern,cjs --no-compress --no-sourcemap --jsx React.createElement src/*.{js,jsx}", + "build": "microbundle -f modern,cjs --no-compress --no-sourcemap --jsx React.createElement src/*.{js,jsx,ts}", "dev:docs": "snowpack dev", "build:docs": "snowpack build" }, @@ -36,6 +37,7 @@ "@babel/core": "^7.14.3", "@babel/plugin-transform-react-jsx": "^7.0.0", "@babel/preset-env": "^7.0.0", + "@types/react": "^17.0.20", "@wojtekmaj/enzyme-adapter-react-17": "^0.6.1", "core-js": "^3.12.1", "enzyme": "^3.11.0", @@ -46,8 +48,10 @@ "raf": "^3.4.1", "react": "^17.0.2", "react-dom": "^17.0.2", + "resize-observer-polyfill": "^1.5.1", "snowpack": "^3.5.0", - "standard": "^16.0.3" + "standard": "^16.0.3", + "typescript": "^4.4.2" }, "standard": { "ignore": [ diff --git a/src/@types/index.d.ts b/src/@types/index.d.ts new file mode 100644 index 0000000..f29cf75 --- /dev/null +++ b/src/@types/index.d.ts @@ -0,0 +1,33 @@ +// /src/@types/index.d.ts +declare module "react-lines-ellipsis" { + import * as React from "react"; + + interface ReactLinesEllipsisProps { + basedOn?: "letters" | "words"; + className?: string; + component?: string; + ellipsis?: string; + isClamped?: () => boolean; + maxLine?: number | string; + onReflow?: ({ clamped, text }: { clamped: boolean; text: string }) => any; + style?: React.CSSProperties; + text?: string; + trimRight?: boolean; + winWidth?: number; + } + + class LinesEllipsis extends React.Component { + static defaultProps?: ReactLinesEllipsisProps; + } + + export default LinesEllipsis; +} + +declare module "react-lines-ellipsis/lib/responsiveHOC" { + import * as React from "react"; + + export default function responsiveHOC():

( + WrappedComponent: React.ComponentType

, + ) => React.ComponentClass

; +} + diff --git a/src/html.jsx b/src/html.jsx index 6eec896..c3fe799 100644 --- a/src/html.jsx +++ b/src/html.jsx @@ -1,4 +1,5 @@ import React from 'react' +import _ from 'lodash' import { canvasStyle, mirrorProps } from './common' import { omit } from './helpers' @@ -39,10 +40,7 @@ function dummySpan (text) { } function unwrapTextNode (node) { - node.parentNode.replaceChild( - document.createTextNode(node.textContent), - node - ) + node.parentNode.replaceChild(document.createTextNode(node.textContent), node) } function removeFollowingElementLeaves (node, root) { @@ -56,14 +54,19 @@ function removeFollowingElementLeaves (node, root) { function findBlockAncestor (node) { let ndAncestor = node while ((ndAncestor = ndAncestor.parentNode)) { - if (/p|div|main|section|h\d|ul|ol|li/.test(ndAncestor.tagName.toLowerCase())) { + if ( + /p|div|main|section|h\d|ul|ol|li/.test(ndAncestor.tagName.toLowerCase()) + ) { return ndAncestor } } } function affectLayout (ndUnit) { - return !!(ndUnit.offsetHeight && (ndUnit.offsetWidth || /\S/.test(ndUnit.textContent))) + return !!( + ndUnit.offsetHeight && + (ndUnit.offsetWidth || /\S/.test(ndUnit.textContent)) + ) } const defaultProps = { @@ -107,7 +110,7 @@ class HTMLEllipsis extends React.Component { if (prevProps.winWidth !== this.props.winWidth) { this.copyStyleToCanvas() } - if (this.props !== prevProps) { + if (!_.isEqual(this.props, prevProps)) { this.reflow(this.props) } } @@ -125,7 +128,7 @@ class HTMLEllipsis extends React.Component { initCanvas () { if (this.canvas) return - const canvas = this.canvas = document.createElement('div') + const canvas = (this.canvas = document.createElement('div')) canvas.className = `LinesEllipsis-canvas ${this.props.className}` canvas.setAttribute('aria-hidden', 'true') this.copyStyleToCanvas() @@ -146,7 +149,9 @@ class HTMLEllipsis extends React.Component { /* eslint-disable no-control-regex */ this.maxLine = +props.maxLine || 1 this.canvas.innerHTML = props.unsafeHTML - const basedOn = props.basedOn || (/^[\x00-\x7F]+$/.test(props.unsafeHTML) ? 'words' : 'letters') + const basedOn = + props.basedOn || + (/^[\x00-\x7F]+$/.test(props.unsafeHTML) ? 'words' : 'letters') hookNode(this.canvas, basedOn) const clamped = this.putEllipsis(this.calcIndexes()) const newState = { @@ -158,7 +163,9 @@ class HTMLEllipsis extends React.Component { calcIndexes () { const indexes = [0] - const nlUnits = this.nlUnits = Array.from(this.canvas.querySelectorAll('.LinesEllipsis-unit')) + const nlUnits = (this.nlUnits = Array.from( + this.canvas.querySelectorAll('.LinesEllipsis-unit') + )) const len = nlUnits.length if (!nlUnits.length) return indexes @@ -190,10 +197,11 @@ class HTMLEllipsis extends React.Component { removeFollowingElementLeaves(ndPrevUnit, this.canvas) findBlockAncestor(ndPrevUnit).appendChild(ndEllipsis) ndPrevUnit = this.nlUnits.pop() - } while (ndPrevUnit && ( - !affectLayout(ndPrevUnit) || - ndEllipsis.offsetHeight > ndPrevUnit.offsetHeight || - ndEllipsis.offsetTop > ndPrevUnit.offsetTop) + } while ( + ndPrevUnit && + (!affectLayout(ndPrevUnit) || + ndEllipsis.offsetHeight > ndPrevUnit.offsetHeight || + ndEllipsis.offsetTop > ndPrevUnit.offsetTop) ) if (ndPrevUnit) { @@ -229,11 +237,15 @@ class HTMLEllipsis extends React.Component { const { component: Component, className, unsafeHTML, ...rest } = this.props return ( (this.target = node)} + className={`LinesEllipsis ${ + clamped ? 'LinesEllipsis--clamped' : '' + } ${className}`} + ref={(node) => (this.target = node)} {...omit(rest, usedProps)} > -

+
) } diff --git a/src/index.jsx b/src/index.jsx index e479beb..ae0dc9b 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -1,4 +1,6 @@ import React from 'react' +import _ from 'lodash' +import ResizeObserver from 'resize-observer-polyfill' import { canvasStyle, mirrorProps } from './common' import { omit } from './helpers' @@ -39,6 +41,8 @@ class LinesEllipsis extends React.Component { this.units = [] this.maxLine = 0 this.canvas = null + this.target = null + this.handleRef = this.handleRef.bind(this) } componentDidMount () { @@ -50,15 +54,58 @@ class LinesEllipsis extends React.Component { if (prevProps.winWidth !== this.props.winWidth) { this.copyStyleToCanvas() } - if (this.props !== prevProps) { + if (!_.isEqual(this.props, prevProps)) { this.reflow(this.props) } } componentWillUnmount () { + if (this.resizeObserver) { + this.resizeObserver.disconnect() + } this.canvas.parentNode.removeChild(this.canvas) } + handleRef (node) { + const isNewNode = this.target !== node + + this.target = node + // whenever we obtain a new element, attach resize handler + if (node && isNewNode) { + this.resizeObserver = this.handleResize(node, this.resizeObserver) + } + } + + handleResize (el, prevResizeObserver) { + // clean up previous observer + if (prevResizeObserver) { + prevResizeObserver.disconnect() + } + + // unmounting or just unsetting the element to be replaced with a new one later + if (!el) return null + + /* Wrapper element resize handing */ + let initialRender = true + const resizeCallback = () => { + if (initialRender) { + // ResizeObserer cb is called on initial render too so we are skipping here + initialRender = false + } else { + // wrapper element has been resized, recalculating with the original text + this.copyStyleToCanvas() + this.reflow(this.props) + } + } + + const resizeObserver = + prevResizeObserver || new ResizeObserver(resizeCallback) + + resizeObserver.observe(el) + + return resizeObserver + } + setState (state, callback) { if (typeof state.clamped !== 'undefined') { this.clamped = state.clamped @@ -68,7 +115,7 @@ class LinesEllipsis extends React.Component { initCanvas () { if (this.canvas) return - const canvas = this.canvas = document.createElement('div') + const canvas = (this.canvas = document.createElement('div')) canvas.className = `LinesEllipsis-canvas ${this.props.className}` canvas.setAttribute('aria-hidden', 'true') this.copyStyleToCanvas() @@ -87,7 +134,9 @@ class LinesEllipsis extends React.Component { reflow (props) { /* eslint-disable no-control-regex */ - const basedOn = props.basedOn || (/^[\x00-\x7F]+$/.test(props.text) ? 'words' : 'letters') + const basedOn = + props.basedOn || + (/^[\x00-\x7F]+$/.test(props.text) ? 'words' : 'letters') switch (basedOn) { case 'words': this.units = props.text.split(/\b|(?=\W)/) @@ -99,9 +148,11 @@ class LinesEllipsis extends React.Component { throw new Error(`Unsupported options basedOn: ${basedOn}`) } this.maxLine = +props.maxLine || 1 - this.canvas.innerHTML = this.units.map((c) => { - return `${c}` - }).join('') + this.canvas.innerHTML = this.units + .map((c) => { + return `${c}` + }) + .join('') const ellipsisIndex = this.putEllipsis(this.calcIndexes()) const clamped = ellipsisIndex > -1 const newState = { @@ -138,17 +189,20 @@ class LinesEllipsis extends React.Component { const lastIndex = indexes[this.maxLine] const units = this.units.slice(0, lastIndex) const maxOffsetTop = this.canvas.children[lastIndex].offsetTop - this.canvas.innerHTML = units.map((c, i) => { - return `${c}` - }).join('') + `${this.props.ellipsis}` + this.canvas.innerHTML = + units + .map((c, i) => { + return `${c}` + }) + .join('') + + `${this.props.ellipsis}` const ndEllipsis = this.canvas.lastElementChild let ndPrevUnit = prevSibling(ndEllipsis, 2) - while (ndPrevUnit && - ( - ndEllipsis.offsetTop > maxOffsetTop || // IE & Edge: doesn't support + while ( + ndPrevUnit && + (ndEllipsis.offsetTop > maxOffsetTop || // IE & Edge: doesn't support ndEllipsis.offsetHeight > ndPrevUnit.offsetHeight || - ndEllipsis.offsetTop > ndPrevUnit.offsetTop - ) + ndEllipsis.offsetTop > ndPrevUnit.offsetTop) ) { this.canvas.removeChild(ndPrevUnit) ndPrevUnit = prevSibling(ndEllipsis, 2) @@ -164,19 +218,24 @@ class LinesEllipsis extends React.Component { render () { const { text, clamped } = this.state - const { component: Component, ellipsis, trimRight, className, ...rest } = this.props + const { + component: Component, + ellipsis, + trimRight, + className, + ...rest + } = this.props return ( (this.target = node)} + className={`LinesEllipsis ${ + clamped ? 'LinesEllipsis--clamped' : '' + } ${className}`} + ref={this.handleRef} {...omit(rest, usedProps)} > - {clamped && trimRight - ? text.replace(/[\s\uFEFF\xA0]+$/, '') - : text} + {clamped && trimRight ? text.replace(/[\s\uFEFF\xA0]+$/, '') : text} - {clamped && - {ellipsis}} + {clamped && {ellipsis}} ) } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a8dc95c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + + "target": "es5", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "module": "commonjs", /* Specify what module code is generated. */ + "typeRoots": ["./node_modules/@types", "./src/"], /* Specify multiple folders that act like `./node_modules/@types`. */ + "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +}