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 | 
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 |
--------------------------------------------------------------------------------