├── .prettierrc ├── .gitignore ├── test ├── ahem.ttf ├── test.css ├── stretch-to-fit.html ├── slanty-blend-mode.html ├── perf.html ├── test.html ├── hammerfall.html └── test.js ├── doc └── lineclamp.webp ├── .npmignore ├── .editorconfig ├── rollup.config.js ├── package.json ├── README.md └── src └── LineClamp.js /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /.idea/ 3 | /extra/ 4 | /dist/ 5 | -------------------------------------------------------------------------------- /test/ahem.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tvanc/lineclamp/HEAD/test/ahem.ttf -------------------------------------------------------------------------------- /doc/lineclamp.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tvanc/lineclamp/HEAD/doc/lineclamp.webp -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /extra/ 3 | /src/ 4 | /rollup.config.js 5 | /.editorconfig 6 | /test/ 7 | /.prettierrc 8 | /doc/ 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset=utf-8 3 | end_of_line=lf 4 | trim_trailing_whitespace=true 5 | insert_final_newline=true 6 | indent_style=space 7 | indent_size=4 8 | 9 | [{composer.lock,.babelrc,.stylelintrc,.eslintrc,jest.config,*.json,*.jsb3,*.jsb2,*.bowerrc}] 10 | indent_style=space 11 | indent_size=2 12 | 13 | [{*.applejs,*.js}] 14 | indent_style=space 15 | indent_size=2 16 | 17 | [{*.yml,*.yaml}] 18 | indent_style=space 19 | indent_size=2 20 | 21 | [yarn.lock] 22 | indent_style=space 23 | indent_size=2 24 | 25 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { terser } from 'rollup-plugin-terser' 2 | 3 | const rollupConfig = { 4 | input: 'src/LineClamp.js', 5 | output: [ 6 | { format: 'esm', file: 'dist/esm.js' }, 7 | { format: 'esm', file: 'dist/esm.min.js', plugins: [terser()] }, 8 | 9 | { format: 'cjs', file: 'dist/index.js', exports: "default" }, 10 | { format: 'cjs', file: 'dist/index.min.js', plugins: [terser()], exports: 'default' }, 11 | 12 | { format: 'umd', file: 'dist/umd.js', name: 'LineClamp' }, 13 | { format: 'umd', file: 'dist/umd.min.js', name: 'LineClamp', plugins: [terser()] }, 14 | ] 15 | } 16 | 17 | export default rollupConfig 18 | -------------------------------------------------------------------------------- /test/test.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | @font-face { 6 | font-family: "Ahem"; 7 | src: url("ahem.ttf"); 8 | } 9 | 10 | #mocha .test pre.error { 11 | white-space: pre-wrap; 12 | } 13 | 14 | .test:hover, 15 | .test:focus-within { 16 | background-color: ghostwhite; 17 | } 18 | 19 | .tester { 20 | display: none; 21 | width: 300px; 22 | background: lightgreen; 23 | font-size: 72px; 24 | line-height: 1; 25 | margin: 15px auto; 26 | } 27 | 28 | .tester.active { 29 | display: block; 30 | } 31 | 32 | .tester:last-child { 33 | background: powderblue; 34 | } 35 | 36 | .ahem { 37 | color: rgba(200, 255, 200, 0.5); 38 | background: rgba(255, 200, 200, 0.8); 39 | font-family: "Ahem", sans-serif; 40 | word-wrap: break-word; 41 | } 42 | -------------------------------------------------------------------------------- /test/stretch-to-fit.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Stretch to Fit | LineClamp 7 | 23 | 40 | 41 | 42 |

Cool!

43 | 44 | 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tvanc/lineclamp", 3 | "version": "0.2.0", 4 | "description": "Limit an element's text to a given height or number of lines.", 5 | "module": "dist/esm.js", 6 | "main": "dist/index.js", 7 | "unpkg": "dist/esm.min.js", 8 | "scripts": { 9 | "test": "browser-sync start -s \"./\" -f \"./test/*\" \"./src/LineClamp.js\" --startPath \"test/test.html\"", 10 | "prepublish": "rollup -c" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/tvanc/lineclamp.git" 15 | }, 16 | "keywords": [ 17 | "clamp", 18 | "lineclamp", 19 | "line-clamp", 20 | "line clamp", 21 | "textclamp", 22 | "text-clamp", 23 | "text clamp", 24 | "max lines", 25 | "text height" 26 | ], 27 | "author": "Travis Van Couvering (https://tvanc.com)", 28 | "license": "ISC", 29 | "homepage": "https://github.com/tvanc/lineclamp", 30 | "bugs": { 31 | "url": "https://github.com/tvanc/lineclamp/issues" 32 | }, 33 | "directories": { 34 | "test": "test", 35 | "lib": "dist" 36 | }, 37 | "devDependencies": { 38 | "browser-sync": "^2.27.9", 39 | "chai": "^4.3.6", 40 | "chai-spies": "^1.0.0", 41 | "mocha": "^9.2.2", 42 | "prettier": "^2.6.2", 43 | "rollup": "^2.70.2", 44 | "rollup-plugin-terser": "^7.0.2" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /test/slanty-blend-mode.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Slanty Blend Mode 7 | 54 | 55 | 56 | 57 |
58 |

Travis Van Couvering

59 | 60 |

The ideal human.

61 |
62 | 63 | 80 | 81 | -------------------------------------------------------------------------------- /test/perf.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 47 | 59 | 60 | 61 |

62 | As large as possible I am always three lines An awful haiku 63 |

64 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /test/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | LineClamp Tests 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 |
13 |

14 | Just plain one line. 15 |

16 | 17 |

18 | One line in original font size. 19 |

20 | 21 |

22 | One line hard clamped. 23 |

24 | 25 |

26 | Soft clamp hardens when needed. 27 |

28 | 29 |

30 | Events trigger properly. 31 |

32 | 33 |

34 | Observer observes 35 |

36 | 37 |

42 | Padding, border, and min-height accounted for 43 |

44 | 45 |

46 | "Here are three lines of text. In an inline element!" – Abraham Lincoln 51 |

52 | 53 |

Nice

54 |

work!

55 |
56 | 57 | 58 | 59 | 60 | 63 | 64 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /test/hammerfall.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Hammer Fall 6 | 7 | 46 | 47 | 73 | 74 | 75 |
76 | 77 |
78 | Ice 79 |
80 |
81 | Cream 82 |
83 |
84 | Sundae 85 |
86 |
87 | Coming 88 |
89 |
90 | This 91 |
92 |
93 | Tuesday 94 |
95 |
96 | ☄️ 97 |
98 |
99 | 💥 100 |
101 |
102 | 🌋 103 |
104 |
105 | 106 | 107 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | import LineClamp from "/src/LineClamp.js" 2 | 3 | const { expect, assert } = chai 4 | 5 | describe("LineClamp", () => { 6 | it("Limits to one line in reduced font size", () => { 7 | const element = getAndShowById("strictTester") 8 | const clamp = new LineClamp(element, { 9 | maxLines: 1, 10 | useSoftClamp: true, 11 | }) 12 | 13 | const softClampSpy = chai.spy.on(clamp, "softClamp") 14 | const hardClampSpy = chai.spy.on(clamp, "hardClamp") 15 | 16 | clamp.apply() 17 | 18 | assert.equal( 19 | clamp.calculateTextMetrics().firstLineHeight, 20 | element.clientHeight, 21 | "Element reduced to one strict line." 22 | ) 23 | 24 | clamp.apply() 25 | 26 | expect(softClampSpy).to.have.been.called() 27 | expect(hardClampSpy).not.to.have.been.called() 28 | }) 29 | 30 | it("Limits to height of one line in original font size", () => { 31 | const element = getAndShowById("heightTester") 32 | const clamp = new LineClamp(element, { useSoftClamp: true }) 33 | const startingLineHeight = clamp.calculateTextMetrics().firstLineHeight 34 | clamp.maxHeight = startingLineHeight 35 | 36 | clamp.apply() 37 | 38 | const currentLineHeight = clamp.calculateTextMetrics().firstLineHeight 39 | const currentHeight = element.clientHeight 40 | 41 | assert.isAbove( 42 | currentHeight, 43 | currentLineHeight, 44 | "Element is taller than one line in its reduced font size." 45 | ) 46 | 47 | assert.strictEqual( 48 | currentHeight / currentLineHeight, 49 | 2, 50 | "Element height is twice current line height (two lines)" 51 | ) 52 | 53 | assert.isAtMost( 54 | currentHeight, 55 | startingLineHeight, 56 | "Current height equal to or less than starting height" 57 | ) 58 | }) 59 | 60 | it("Hard clamps to one line", () => { 61 | const element = getAndShowById("hardClampTester") 62 | const clamp = new LineClamp(element, { 63 | maxLines: 1, 64 | useSoftClamp: false, 65 | }) 66 | 67 | clamp.apply() 68 | 69 | const { firstLineHeight } = clamp.calculateTextMetrics() 70 | 71 | assert.isTrue( 72 | element.clientHeight === firstLineHeight, 73 | "Element is only one line high" 74 | ) 75 | }) 76 | 77 | it("Soft clamp hardens if necessary", () => { 78 | const element = getAndShowById("softClampTester") 79 | const clamp = new LineClamp(element, { 80 | maxLines: 1, 81 | minFontSize: 48, 82 | useSoftClamp: true, 83 | strict: true, 84 | }) 85 | 86 | const softClampSpy = chai.spy.on(clamp, "softClamp") 87 | const hardClampSpy = chai.spy.on(clamp, "hardClamp") 88 | 89 | clamp.apply() 90 | 91 | expect(softClampSpy).to.have.been.called() 92 | expect(hardClampSpy).to.have.been.called() 93 | }) 94 | 95 | it("Event order: softclamp, hardclamp, clamp", () => { 96 | const element = getAndShowById("eventsTester") 97 | const clamp = new LineClamp(element, { 98 | useSoftClamp: true, 99 | maxLines: 1, 100 | }) 101 | 102 | // Guarantee softClamp() will escalate to hardClamp() 103 | clamp.minFontSize = clamp.maxFontSize - 1 104 | 105 | let softClampTriggeredFirst = false 106 | let hardClampTriggeredNext = false 107 | let plainClampTriggeredLast = false 108 | 109 | element.addEventListener( 110 | "lineclamp.softclamp", 111 | // Ensure correct order 112 | () => (softClampTriggeredFirst = !hardClampTriggeredNext) 113 | ) 114 | 115 | element.addEventListener( 116 | "lineclamp.hardclamp", 117 | // Ensure correct order 118 | () => (hardClampTriggeredNext = softClampTriggeredFirst) 119 | ) 120 | 121 | element.addEventListener( 122 | "lineclamp.clamp", 123 | () => (plainClampTriggeredLast = hardClampTriggeredNext) 124 | ) 125 | 126 | clamp.apply() 127 | 128 | assert(softClampTriggeredFirst, "Soft clamp triggered first") 129 | assert(hardClampTriggeredNext, "Hard clamp triggered next") 130 | assert(plainClampTriggeredLast, "Plain clamp triggered last") 131 | }) 132 | 133 | it("Reclamps on DOM mutation", (done) => { 134 | const element = getAndShowById("mutationTester") 135 | const clamp = new LineClamp(element, { minFontSize: 48, maxLines: 1 }) 136 | const clampSpy = chai.spy.on(clamp, "apply") 137 | 138 | clamp.watch() 139 | 140 | expect(clampSpy).not.to.have.been.called() 141 | 142 | element.addEventListener("lineclamp.hardclamp", () => { 143 | expect(clampSpy).to.have.been.called() 144 | done() 145 | }) 146 | 147 | element.innerHTML = element.innerHTML + " " 148 | }) 149 | 150 | it("Padding, border, min-height, and font-size are taken into account", () => { 151 | const element = getAndShowById("dimensionsTester") 152 | const clamp = new LineClamp(element, { maxLines: 1 }) 153 | 154 | clamp.apply() 155 | 156 | const { firstLineHeight } = clamp.calculateTextMetrics() 157 | const currentHeight = element.offsetHeight 158 | 159 | assert.isAbove( 160 | currentHeight, 161 | firstLineHeight, 162 | "Element is taller than the line height." 163 | ) 164 | }) 165 | 166 | it("Works for inline text", () => { 167 | // We have to just take this for granted. There's no other way to get the 168 | // number of lines to test against. 169 | const expectedLineCount = 3 170 | const element = getAndShowById("displayInlineTester") 171 | const clamp = new LineClamp(element, { maxLines: expectedLineCount }) 172 | const { lineCount } = clamp.calculateTextMetrics() 173 | 174 | // How do I prove there are three lines algorithmically? 175 | assert.equal(lineCount, expectedLineCount, "Inline text is correct height.") 176 | }) 177 | 178 | it( 179 | "No softclamp event triggered if no change in font size", 180 | getNoOpTest( 181 | "softClampOnlyFiresIfTriggeredTester", 182 | "lineclamp.softclamp", 183 | true 184 | ) 185 | ) 186 | 187 | it( 188 | "No hardclamp event if no lines removed", 189 | getNoOpTest( 190 | "hardClampOnlyFiresIfTriggeredTester", 191 | "lineclamp.hardclamp", 192 | false 193 | ) 194 | ) 195 | }) 196 | 197 | function getAndShowById(id) { 198 | const el = document.getElementById(id) 199 | const tester = el.closest(".tester") 200 | 201 | tester.classList.add("active") 202 | 203 | return el 204 | } 205 | 206 | function getNoOpTest(elId, event, useSoftClamp) { 207 | return () => { 208 | const el = getAndShowById(elId) 209 | const clamp = new LineClamp(el, { maxLines: 1, useSoftClamp }) 210 | const handler = (e) => console.log(`[${event} test]: ${e.type} fired`) 211 | 212 | // Listener for event type, `event` 213 | const givenEventSpy = chai.spy(handler) 214 | const genericEventSpy = chai.spy(handler) 215 | 216 | el.addEventListener(event, givenEventSpy) 217 | el.addEventListener("lineclamp.clamp", genericEventSpy) 218 | 219 | clamp.apply() 220 | 221 | expect(givenEventSpy).not.to.have.been.called() 222 | expect(genericEventSpy).not.to.have.been.called() 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LineClamp 2 | 3 | ![Animation showing text resizing to stay one line long regardless of container width](./doc/lineclamp.webp) 4 | 5 | Limit text to a given height or number of lines by reducing font 6 | size or trimming text. Works on inline and block elements with any combination 7 | of border, padding, line-height, min-height, and max-height. 8 | 9 | ## Installation 10 | 11 | ```bash 12 | npm install @tvanc/lineclamp 13 | ``` 14 | 15 | ## Examples 16 | 17 | View [examples](https://codepen.io/collection/AEwzoQ/) on CodePen. 18 | 19 | ## Usage 20 | 21 | ```javascript 22 | import LineClamp from "@tvanc/lineclamp" 23 | const element = document.getElementById("#long-marketing-title") 24 | 25 | // Create a clamp set to one line 26 | const clamp = new LineClamp(element, { maxLines: 1 }) 27 | 28 | // Apply the clamp. 29 | clamp.apply() 30 | 31 | // Watch for layout changes, reclamp if necessary 32 | clamp.watch() 33 | 34 | // Stop watching 35 | clamp.unwatch() 36 | 37 | // Get text metrics (total height, number of lines, line heights) 38 | // https://github.com/tvanc/lineclamp#getting-text-metrics 39 | clamp.calculateTextMetrics() 40 | ``` 41 | 42 | ### Methods 43 | 44 | | Method | Description | 45 | | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 46 | | `watch()` | Watch for changes. | 47 | | `unwatch()` | Stop watching for changes. | 48 | | `apply()` | Apply the clamp. Whether `softClamp()` or `hardClamp()` is used depends on the value of the `useSoftClamp` option. | 49 | | `softClamp()` | Reduce font size until text height or line count are within constraints. If font size is reduced to `minFontSize` and text still exceeds constraints, optionally resort to using `hardClamp()`. | 50 | | `hardClamp()` | Trim text content to force it to fit within the maximum number of lines. | 51 | | `shouldClamp()` | Detect whether text exceeds the specified `maxHeight` or `maxLines`. | 52 | | [`calculateTextMetrics()`](#getting-text-metrics) | Get metrics regarding the element's text, like number of lines, text height, and line height. | 53 | 54 | ### Options 55 | 56 | These options can be passed as the second argument to the constructor, or set 57 | directly on the object. 58 | 59 | | Option | Type | Default | Description | 60 | | --------------------- | ------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 61 | | `maxLines` | Number | `1` | The maximum number of lines to allow. Defaults to 1. To set a maximum height instead, use `maxHeight`. | 62 | | `maxHeight` | Number | `undefined` | The maximum height (in pixels) of text in an element. This option is undefined by default. Once set, it takes precedence over `maxLines`. Note that this applies to the height of the text, not the element itself. | 63 | | `useSoftClamp` | Boolean | `false` | Whether to attempt soft clamping before resorting to hard clamping. | 64 | | `hardClampAsFallback` | Boolean | `true` | If true, resort to hard clamping if soft clamping reaches the minimum font size and still doesn't fit within the max height or number of lines | 65 | | `ellipsis` | Boolean | `1` | The character with which to represent clipped trailing text. This option takes effect when "hard" clamping is used. | 66 | | `minFontSize` | Boolean | `1` | The minimum font size before a soft clamp turns into a hard clamp. | 67 | | `maxFontSize` | Boolean | computed font-size | The maximum font size to use for the element when soft clamping. We start with this number and then decrement towards `minFontSize`. | 68 | 69 | ### Events 70 | 71 | Add listeners to the clamped element, not the clamp itself. 72 | 73 | | Event | Description | 74 | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 75 | | `lineclamp.softclamp` | Emitted when the element is softly clamped. | 76 | | `lineclamp.hardclamp` | Emitted when the element is hard clamped. | 77 | | `lineclamp.clamp` | Emitted when any kind of clamping occurs. If `.apply()` results in both soft and hard clamping, only one `lineclamp.clamp` event is issued, after `lineclamp.softclamp` and `lineclamp.hardclamp` have fired. | 78 | 79 | #### Example 80 | 81 | ```javascript 82 | import LineClamp from "@tvanc/lineclamp" 83 | const element = document.getElementById("#clampedElement") 84 | 85 | const clamp = new LineClamp(element) 86 | const listener = (event) => console.log(event.type) 87 | 88 | element.addEventListener("lineclamp.softclamp", listener) 89 | element.addEventListener("lineclamp.hardclamp", listener) 90 | element.addEventListener("lineclamp.clamp", listener) 91 | 92 | // softClamp() emits 'lineclamp.softclamp' and 'lineclamp.clamp', or nothing 93 | // if clamping unnecessary 94 | clamp.softClamp() 95 | 96 | // hardClamp() emits 'lineclamp.hardclamp' and 'lineclamp.clamp', or nothing 97 | // if clamping unnecessary 98 | clamp.hardClamp() 99 | 100 | // apply() can emit 'lineclamp.softclamp' and/or 'lineclamp.hardclamp' followed 101 | // by 'lineclamp.clamp', or nothing if clamping is unnecessary 102 | clamp.apply() 103 | ``` 104 | 105 | ### Getting Text Metrics 106 | 107 | Unfortunately, there is no native API for counting the number of lines or 108 | determining line height. The computed CSS line-height can return `normal`, 109 | which isn't useful for calculations. The only (mostly) sure-fire solution is to 110 | compare the height of the element with no text to the height of the element 111 | with one line of text. That gets you the height of the first line. 112 | 113 | Subsequent lines can have different heights than the first - though 114 | all subsequent lines will be the same height as each other 115 | (barring things that can distort line heights, like certain characters). So you 116 | have to add another line to know the height of the next lines. 117 | 118 | This module does all that. The information it gleans is made available via the 119 | `calculateTextMetrics()` method: 120 | 121 | ```javascript 122 | import LineClamp from "@tvanc/lineclamp" 123 | const element = document.getElementById("#long-marketing-title") 124 | const clamp = new LineClamp(element) 125 | 126 | // Get text metrics 127 | const metrics = clamp.calculateTextMetrics() 128 | ``` 129 | 130 | `calculateTextMetrics()` returns an object with the following information. 131 | 132 | | Property | Description | 133 | | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 134 | | `textHeight` | The vertical space in pixels required to display the element's current text. | 135 | | `naturalHeightWithOneLine` | The height of the element with only one line of text and without minimum or maximum heights. | 136 | | `firstLineHeight` | The height that the first line of text adds to the element, i.e., the difference between the height of the element while empty and the height of the element while it contains one line of text. This number may be zero for inline elements because the first line of text does not increase the height of inline elements. | 137 | | `additionalLineHeight` | The height that each line of text after the first adds to the element. | 138 | | `lineCount` | The number of lines of text the element contains. | 139 | -------------------------------------------------------------------------------- /src/LineClamp.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Reduces font size or trims text to make it fit within specified bounds. 3 | * 4 | * Supports clamping by number of lines or text height. 5 | * 6 | * Known limitations: 7 | * 1. Characters that distort line heights (emojis, zalgo) may cause 8 | * unexpected results. 9 | * 2. Calling {@see hardClamp()} wipes child elements. Future updates may allow 10 | * inline child elements to be preserved. 11 | * 12 | * @todo Split text metrics into own library 13 | * @todo Test non-LTR text 14 | */ 15 | export default class LineClamp { 16 | /** 17 | * @param {HTMLElement} element 18 | * The element to clamp. 19 | * 20 | * @param {Object} [options] 21 | * Options to govern clamping behavior. 22 | * 23 | * @param {number} [options.maxLines] 24 | * The maximum number of lines to allow. Defaults to 1. 25 | * To set a maximum height instead, use {@see options.maxHeight} 26 | * 27 | * @param {number} [options.maxHeight] 28 | * The maximum height (in pixels) of text in an element. 29 | * This option is undefined by default. Once set, it takes precedence over 30 | * {@see options.maxLines}. Note that this applies to the height of the text, not 31 | * the element itself. Restricting the height of the element can be achieved 32 | * with CSS max-height. 33 | * 34 | * @param {boolean} [options.useSoftClamp] 35 | * If true, reduce font size (soft clamp) to at least {@see options.minFontSize} 36 | * before resorting to trimming text. Defaults to false. 37 | * 38 | * @param {boolean} [options.hardClampAsFallback] 39 | * If true, resort to hard clamping if soft clamping reaches the minimum font size 40 | * and still doesn't fit within the max height or number of lines. 41 | * Defaults to true. 42 | * 43 | * @param {string} [options.ellipsis] 44 | * The character with which to represent clipped trailing text. 45 | * This option takes effect when "hard" clamping is used. 46 | * 47 | * @param {number} [options.minFontSize] 48 | * The lowest font size, in pixels, to try before resorting to removing 49 | * trailing text (hard clamping). Defaults to 1. 50 | * 51 | * @param {number} [options.maxFontSize] 52 | * The maximum font size in pixels. We'll start with this font size then 53 | * reduce until text fits constraints, or font size is equal to 54 | * {@see options.minFontSize}. Defaults to the element's initial computed font size. 55 | */ 56 | constructor( 57 | element, 58 | { 59 | maxLines = undefined, 60 | maxHeight = undefined, 61 | useSoftClamp = false, 62 | hardClampAsFallback = true, 63 | minFontSize = 1, 64 | maxFontSize = undefined, 65 | ellipsis = "…", 66 | } = {} 67 | ) { 68 | Object.defineProperty(this, "originalWords", { 69 | writable: false, 70 | value: element.textContent.match(/\S+\s*/g) || [], 71 | }) 72 | 73 | Object.defineProperty(this, "updateHandler", { 74 | writable: false, 75 | value: () => this.apply(), 76 | }) 77 | 78 | Object.defineProperty(this, "observer", { 79 | writable: false, 80 | value: new MutationObserver(this.updateHandler), 81 | }) 82 | 83 | if (undefined === maxFontSize) { 84 | maxFontSize = parseInt(window.getComputedStyle(element).fontSize, 10) 85 | } 86 | 87 | this.element = element 88 | this.maxLines = maxLines 89 | this.maxHeight = maxHeight 90 | this.useSoftClamp = useSoftClamp 91 | this.hardClampAsFallback = hardClampAsFallback 92 | this.minFontSize = minFontSize 93 | this.maxFontSize = maxFontSize 94 | this.ellipsis = ellipsis 95 | } 96 | 97 | /** 98 | * Gather metrics about the layout of the element's text. 99 | * This is a somewhat expensive operation - call with care. 100 | * 101 | * @returns {TextMetrics} 102 | * Layout metrics for the clamped element's text. 103 | */ 104 | calculateTextMetrics() { 105 | const element = this.element 106 | const clone = element.cloneNode(true) 107 | const style = clone.style 108 | 109 | // Append, don't replace 110 | style.cssText += ";min-height:0!important;max-height:none!important" 111 | element.replaceWith(clone) 112 | 113 | const naturalHeight = clone.offsetHeight 114 | 115 | // Clear to measure empty height. textContent faster than innerHTML 116 | clone.textContent = "" 117 | 118 | const naturalHeightWithoutText = clone.offsetHeight 119 | const textHeight = naturalHeight - naturalHeightWithoutText 120 | 121 | // Fill element with single non-breaking space to find height of one line 122 | clone.textContent = "\xa0" 123 | 124 | // Get height of element with only one line of text 125 | const naturalHeightWithOneLine = clone.offsetHeight 126 | const firstLineHeight = naturalHeightWithOneLine - naturalHeightWithoutText 127 | 128 | // Add line (
+ nbsp). appendChild() faster than innerHTML 129 | clone.appendChild(document.createElement("br")) 130 | clone.appendChild(document.createTextNode("\xa0")) 131 | 132 | const additionalLineHeight = clone.offsetHeight - naturalHeightWithOneLine 133 | const lineCount = 134 | 1 + (naturalHeight - naturalHeightWithOneLine) / additionalLineHeight 135 | 136 | // Restore original content 137 | clone.replaceWith(element) 138 | 139 | /** 140 | * @typedef {Object} TextMetrics 141 | * 142 | * @property {textHeight} 143 | * The vertical space required to display the element's current text. 144 | * This is not necessarily the same as the height of the element. 145 | * This number may even be greater than the element's height in cases 146 | * where the text overflows the element's block axis. 147 | * 148 | * @property {naturalHeightWithOneLine} 149 | * The height of the element with only one line of text and without 150 | * minimum or maximum heights. This information may be helpful when 151 | * dealing with inline elements (and potentially other scenarios), where 152 | * the first line of text does not increase the element's height. 153 | * 154 | * @property {firstLineHeight} 155 | * The height that the first line of text adds to the element, i.e., the 156 | * difference between the height of the element while empty and the height 157 | * of the element while it contains one line of text. This number may be 158 | * zero for inline elements because the first line of text does not 159 | * increase the height of inline elements. 160 | 161 | * @property {additionalLineHeight} 162 | * The height that each line of text after the first adds to the element. 163 | * 164 | * @property {lineCount} 165 | * The number of lines of text the element contains. 166 | */ 167 | return { 168 | textHeight, 169 | naturalHeightWithOneLine, 170 | firstLineHeight, 171 | additionalLineHeight, 172 | lineCount, 173 | } 174 | } 175 | 176 | /** 177 | * Watch for changes that may affect layout. Respond by reclamping if 178 | * necessary. 179 | */ 180 | watch() { 181 | if (!this._watching) { 182 | window.addEventListener("resize", this.updateHandler) 183 | 184 | // Minimum required to detect changes to text nodes, 185 | // and wholesale replacement via innerHTML 186 | this.observer.observe(this.element, { 187 | characterData: true, 188 | subtree: true, 189 | childList: true, 190 | attributes: true, 191 | }) 192 | 193 | /** 194 | * TODO: Further research using `ResizeObserver` 195 | * 196 | * Current drawbacks: 197 | * - Clamping occurs less frequently making changes look stilted when resizing window. 198 | * - Changes behavior: Elements which previously changed size because they were reclamped no longer will. 199 | * - Fires immediately upon calling `.observe()`. Not a huge deal. Observer just needs to change not to 200 | * fire if `false === this._watching` 201 | */ 202 | // this.resizeObserver.observe(this.element) 203 | 204 | this._watching = true 205 | } 206 | 207 | return this 208 | } 209 | 210 | /** 211 | * Stop watching for layout changes. 212 | * 213 | * @returns {LineClamp} 214 | */ 215 | unwatch() { 216 | this.observer.disconnect() 217 | window.removeEventListener("resize", this.updateHandler) 218 | 219 | // this.resizeObserver.disconnect() 220 | 221 | this._watching = false 222 | 223 | return this 224 | } 225 | 226 | /** 227 | * Conduct either soft clamping or hard clamping, according to the value of 228 | * property {@see LineClamp.useSoftClamp}. 229 | */ 230 | apply() { 231 | if (this.element.offsetHeight) { 232 | const previouslyWatching = this._watching 233 | 234 | // Ignore internally started mutations, lest we recurse into oblivion 235 | this.unwatch() 236 | 237 | this.element.textContent = this.originalWords.join("") 238 | 239 | if (this.useSoftClamp) { 240 | this.softClamp() 241 | } else { 242 | this.hardClamp() 243 | } 244 | 245 | // Resume observation if previously watching 246 | if (previouslyWatching) { 247 | this.watch(false) 248 | } 249 | } 250 | 251 | return this 252 | } 253 | 254 | /** 255 | * Trims text until it fits within constraints 256 | * (maximum height or number of lines). 257 | * 258 | * @see {LineClamp.maxLines} 259 | * @see {LineClamp.maxHeight} 260 | */ 261 | hardClamp(skipCheck = true) { 262 | if (skipCheck || this.shouldClamp()) { 263 | let currentText 264 | 265 | findBoundary( 266 | 1, 267 | this.originalWords.length, 268 | (val) => { 269 | currentText = this.originalWords.slice(0, val).join(" ") 270 | this.element.textContent = currentText 271 | 272 | return this.shouldClamp() 273 | }, 274 | (val, min, max) => { 275 | // Add one more word if not on max 276 | if (val > min) { 277 | currentText = this.originalWords.slice(0, max).join(" ") 278 | } 279 | 280 | // Then trim letters until it fits 281 | do { 282 | currentText = currentText.slice(0, -1) 283 | this.element.textContent = currentText + this.ellipsis 284 | } while (this.shouldClamp()) 285 | 286 | // Broadcast more specific hardClamp event first 287 | emit(this, "lineclamp.hardclamp") 288 | emit(this, "lineclamp.clamp") 289 | } 290 | ) 291 | } 292 | 293 | return this 294 | } 295 | 296 | /** 297 | * Reduces font size until text fits within the specified height or number of 298 | * lines. Resorts to using {@see hardClamp()} if text still exceeds clamp 299 | * parameters. 300 | */ 301 | softClamp() { 302 | const style = this.element.style 303 | const startSize = window.getComputedStyle(this.element).fontSize 304 | style.fontSize = "" 305 | 306 | let done = false 307 | let shouldClamp 308 | 309 | findBoundary( 310 | this.minFontSize, 311 | this.maxFontSize, 312 | (val) => { 313 | style.fontSize = val + "px" 314 | shouldClamp = this.shouldClamp() 315 | return shouldClamp 316 | }, 317 | (val, min) => { 318 | if (val > min) { 319 | style.fontSize = min + "px" 320 | shouldClamp = this.shouldClamp() 321 | } 322 | done = !shouldClamp 323 | } 324 | ) 325 | 326 | const changed = style.fontSize !== startSize 327 | 328 | // Emit specific softClamp event first 329 | if (changed) { 330 | emit(this, "lineclamp.softclamp") 331 | } 332 | 333 | // Don't emit `lineclamp.clamp` event twice. 334 | if (!done && this.hardClampAsFallback) { 335 | this.hardClamp(false) 336 | } else if (changed) { 337 | // hardClamp emits `lineclamp.clamp` too. Only emit from here if we're 338 | // not also hard clamping. 339 | emit(this, "lineclamp.clamp") 340 | } 341 | 342 | return this 343 | } 344 | 345 | /** 346 | * @returns {boolean} 347 | * Whether height of text or number of lines exceed constraints. 348 | * 349 | * @see LineClamp.maxHeight 350 | * @see LineClamp.maxLines 351 | */ 352 | shouldClamp() { 353 | const { lineCount, textHeight } = this.calculateTextMetrics() 354 | 355 | if (undefined !== this.maxHeight && undefined !== this.maxLines) { 356 | return textHeight > this.maxHeight || lineCount > this.maxLines 357 | } 358 | 359 | if (undefined !== this.maxHeight) { 360 | return textHeight > this.maxHeight 361 | } 362 | 363 | if (undefined !== this.maxLines) { 364 | return lineCount > this.maxLines 365 | } 366 | 367 | throw new Error( 368 | "maxLines or maxHeight must be set before calling shouldClamp()." 369 | ) 370 | } 371 | } 372 | 373 | const fractionalDigits = 2 374 | const minStep = round(1 - `0.${new Array(fractionalDigits).fill(9).join("")}`) 375 | 376 | /** 377 | * Performs a binary search for the maximum whole number in a contiguous range 378 | * where a given test callback will go from returning true to returning false. 379 | * 380 | * Since this uses a binary-search algorithm this is an O(log n) function, 381 | * where n = max - min. 382 | * 383 | * @param {Number} min 384 | * The lower boundary of the range. 385 | * 386 | * @param {Number} max 387 | * The upper boundary of the range. 388 | * 389 | * @param test 390 | * A callback that receives the current value in the range and returns a truthy or falsy value. 391 | * 392 | * @param done 393 | * A function to perform when complete. Receives the following parameters 394 | * - cursor 395 | * - maxPassingValue 396 | * - minFailingValue 397 | */ 398 | function findBoundary(min, max, test, done) { 399 | let cursor = max 400 | 401 | while (max > min) { 402 | if (test(cursor)) { 403 | max = cursor 404 | } else { 405 | min = cursor 406 | } 407 | 408 | if (round(max - min) === minStep) { 409 | done(cursor, min, max) 410 | break 411 | } 412 | 413 | cursor = round((min + max) / 2) 414 | } 415 | } 416 | 417 | function emit(instance, type) { 418 | instance.element.dispatchEvent(new CustomEvent(type)) 419 | } 420 | 421 | function round(num) { 422 | return num.toFixed(fractionalDigits) * 1 423 | } 424 | --------------------------------------------------------------------------------