├── .babelrc.js ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── LICENSE ├── README.md ├── demo └── Resizable.js ├── docs ├── .nojekyll ├── _next │ └── static │ │ ├── RdOs3pPMQa1yRsOzgnCk3 │ │ └── pages │ │ │ ├── _app.js │ │ │ ├── _error.js │ │ │ └── index.js │ │ ├── chunks │ │ └── commons.d9a3026572debc5decea.js │ │ └── runtime │ │ ├── main-ccf83ca188a7eb8ec4cc.js │ │ └── webpack-42652fa8b82c329c0559.js └── index.html ├── markdown.config.js ├── next.config.js ├── package.json ├── pages ├── _document.js └── index.js ├── scripts ├── extract-docs.js └── markdown-docs.js ├── src ├── FontObserver.js ├── FontObserverContext.js ├── FontObserverProvider.js ├── Justify.js ├── PreventWidows.js ├── ResizeObserver.js ├── TightenText.js ├── TightenText.md ├── Typesetting.js ├── TypesettingContext.js ├── TypesettingProvider.js ├── binarySearch.js ├── domUtils.js └── index.js └── yarn.lock /.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | "next/babel", 5 | { 6 | cjs: { "preset-env": { modules: "commonjs" } }, 7 | esm: { "preset-env": { modules: false } } 8 | }[process.env.BABEL_ENV] || {} 9 | ] 10 | ], 11 | plugins: [["babel-plugin-styled-components", { ssr: true }]] 12 | }; 13 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exogen/react-typesetting/7114cdc8c4cb1b0d59ebc8b5364e808687419889/.eslintignore -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["standard", "standard-react", "prettier", "prettier/standard"], 3 | env: { 4 | es6: true, 5 | node: true, 6 | jest: true 7 | }, 8 | parser: "babel-eslint", 9 | parserOptions: { 10 | ecmaVersion: 2018 11 | }, 12 | plugins: ["react", "prettier"], 13 | rules: { 14 | "prettier/prettier": ["warn", {}], 15 | "react/no-unused-prop-types": "warn" 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # Build cache 61 | .cache 62 | 63 | # next.js build output 64 | .next 65 | 66 | # Build output 67 | dist-* 68 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Brian Beck 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # ❧ react-typesetting ☙ 4 | 5 | React components for creating beautifully typeset designs. 6 | 7 | **[Demo!](https://exogen.github.io/react-typesetting)** 8 | 9 | --- 10 | 11 |
12 | 13 | :warning: This project is highly experimental. Use at your own risk! 14 | 15 | ## Table of Contents 16 | 17 | 18 | - [Components](#components) 19 | * [TightenText](#tightentext) 20 | + [Props](#props) 21 | * [PreventWidows](#preventwidows) 22 | + [Props](#props-1) 23 | * [Justify](#justify) 24 | + [Props](#props-2) 25 | * [FontObserver](#fontobserver) 26 | + [Props](#props-3) 27 | * [FontObserver.Provider](#fontobserverprovider) 28 | + [Props](#props-4) 29 | * [Typesetting.Provider](#typesettingprovider) 30 | + [Props](#props-5) 31 | 32 | 33 | ## Components 34 | 35 | 36 | 37 | ### TightenText 38 | 39 | ```js 40 | import { TightenText } from 'react-typesetting'; 41 | ``` 42 | 43 | Tightens `word-spacing`, `letter-spacing`, and `font-size` (in that order) 44 | by the minimum amount necessary to ensure a minimal number of wrapped lines 45 | and overflow. 46 | 47 | The algorithm starts by setting the minimum of all values (defined by the 48 | `minWordSpacing`, `minLetterSpacing`, and `minFontSize` props) to determine 49 | whether adjusting these will result in fewer wrapped lines or less overflow. 50 | If so, then a binary search is performed (with at most `maxIterations`) to 51 | find the best fit. 52 | 53 | By default, element resizes that may necessitate refitting the text are 54 | automatically detected. By specifying the `reflowKey` prop, you can instead 55 | take manual control by changing the prop whenever you’d like the component to 56 | update. 57 | 58 | Note that unlike with typical justified text, the fit adjustments must apply 59 | to all lines of the text, not just the lines that need to be tightened, 60 | because there is no way to target individual wrapped lines. Thus, this 61 | component is best used sparingly for typographically important short runs 62 | of text, like titles or labels. 63 | 64 | #### Props 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 85 | 86 | 87 | 88 | 89 | 90 | 96 | 97 | 98 | 99 | 100 | 101 | 106 | 107 | 108 | 109 | 110 | 111 | 117 | 118 | 119 | 120 | 121 | 122 | 128 | 129 | 130 | 131 | 132 | 133 | 139 | 140 | 141 | 142 | 143 | 144 | 151 | 152 | 153 | 154 | 159 | 160 | 167 | 168 | 169 | 170 | 171 | 172 | 179 | 180 | 181 | 182 | 183 | 184 | 190 | 191 | 192 | 193 | 194 | 195 | 201 | 202 | 203 | 204 | 205 | 206 | 213 | 214 | 215 |
NameTypeDefaultDescription
classNameString 81 | 82 | The class to apply to the outer wrapper `span` created by this component. 83 | 84 |
styleObject 91 | 92 | Extra style properties to add to the outer wrapper `span` created by this 93 | component. 94 | 95 |
childrenNode 102 | 103 | The content to render. 104 | 105 |
minWordSpacingNumber-0.02 112 | 113 | Minimum word spacing in ems. Set this to 0 if word spacing should not be 114 | adjusted. 115 | 116 |
minLetterSpacingNumber-0.02 123 | 124 | Minimum letter spacing in ems. Set this to 0 if word spacing should not 125 | be adjusted. 126 | 127 |
minFontSizeNumber0.97 134 | 135 | Minimum `font-size` in ems. Set this to 1 if font size should not be 136 | adjusted. 137 | 138 |
maxIterationsNumber5 145 | 146 | When performing a binary search to find the optimal value of each CSS 147 | property, this sets the maximum number of iterations to run before 148 | settling on a value. 149 | 150 |
reflowKey 155 | One of…
156 |   Number
157 |   String 158 |
161 | 162 | If specified, disables automatic reflow so that you can trigger it 163 | manually by changing this value. The prop itself does nothing, but 164 | changing it will cause React to update the component. 165 | 166 |
reflowTimeoutNumber 173 | 174 | Debounces reflows so they happen at most this often in milliseconds (at 175 | the end of the given duration). If not specified, reflow is computed 176 | every time the component is rendered. 177 | 178 |
disabledBoolean 185 | 186 | Whether to completely disable refitting the text. Any fit adjustments 187 | that have already been applied in a previous render will be preserved. 188 | 189 |
onReflowFunction 196 | 197 | A function to call when layout has been recomputed and the text is done 198 | refitting. 199 | 200 |
presetString 207 | 208 | The name of a preset defined in an outer `Typesetting.Provider` 209 | component. If it exists, default values for all other props will come 210 | from the specified preset. 211 | 212 |
216 | 217 | ### PreventWidows 218 | 219 | ```js 220 | import { PreventWidows } from 'react-typesetting'; 221 | ``` 222 | Prevents [widows](https://www.fonts.com/content/learning/fontology/level-2/text-typography/rags-widows-orphans) 223 | by measuring the width of the last line of text rendered by the component’s 224 | children. Spaces will be converted to non-breaking spaces until the given 225 | minimum width or the maximum number of substitutions is reached. 226 | 227 | By default, element resizes that may necessitate recomputing line widths are 228 | automatically detected. By specifying the `reflowKey` prop, you can instead 229 | take manual control by changing the prop whenever you’d like the component to 230 | update. 231 | 232 | #### Props 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 253 | 254 | 255 | 256 | 257 | 258 | 264 | 265 | 266 | 267 | 268 | 269 | 274 | 275 | 276 | 277 | 278 | 279 | 284 | 285 | 286 | 287 | 293 | 294 | 308 | 309 | 310 | 311 | 317 | 318 | 334 | 335 | 336 | 337 | 342 | 343 | 350 | 351 | 352 | 353 | 354 | 355 | 362 | 363 | 364 | 365 | 366 | 367 | 372 | 373 | 374 | 375 | 376 | 377 | 383 | 384 | 385 | 386 | 387 | 388 | 395 | 396 | 397 |
NameTypeDefaultDescription
classNameString 249 | 250 | The class to apply to the outer wrapper `span` created by this component. 251 | 252 |
styleObject 259 | 260 | Extra style properties to add to the outer wrapper `span` created by this 261 | component. 262 | 263 |
childrenNode 270 | 271 | The content to render. 272 | 273 |
maxSubstitutionsNumber3 280 | 281 | The maximum number of spaces to substitute. 282 | 283 |
minLineWidth 288 | One of…
289 |   Number
290 |   String
291 |   Function 292 |
15% 295 | 296 | The minimum width of the last line, below which non-breaking spaces will 297 | be inserted until the minimum is met. 298 | 299 | * **Numbers** indicate an absolute pixel width. 300 | * **Strings** indicate a CSS `width` value that will be computed by 301 | temporarily injecting an element into the container and determining its 302 | width. 303 | * **Functions** will be called with relevant data to determine a dynamic 304 | number or string value to return. This can be used, for example, to 305 | have different rules at different breakpoints – like a media query. 306 | 307 |
nbspChar 312 | One of…
313 |   String
314 |   React Element
315 |   Function 316 |
\u00A0 319 | 320 | A character or element to use when substituting spaces. Defaults to a 321 | standard non-breaking space character, which you should almost certainly 322 | stick with unless you want to visualize where non-breaking spaces are 323 | being inserted for debugging purposes, or adjust their width. 324 | 325 | * **String** values will be inserted directly into the existing Text node 326 | containing the space. 327 | * **React Element** values will be rendered into an in-memory “incubator” 328 | node, then transplanted into the DOM, splitting up the Text node in 329 | which the space was found. 330 | * **Function** values must produce a string, Text node, Element node, or 331 | React Element to insert. 332 | 333 |
reflowKey 338 | One of…
339 |   Number
340 |   String 341 |
344 | 345 | If specified, disables automatic reflow so that you can trigger it 346 | manually by changing this value. The prop itself does nothing, but 347 | changing it will cause React to update the component. 348 | 349 |
reflowTimeoutNumber 356 | 357 | Debounces reflows so they happen at most this often in milliseconds (at 358 | the end of the given duration). If not specified, reflow is computed 359 | every time the component is rendered. 360 | 361 |
disabledBoolean 368 | 369 | Whether to completely disable widow prevention. 370 | 371 |
onReflowFunction 378 | 379 | A function to call when layout has been recomputed and space substitution 380 | is done. 381 | 382 |
presetString 389 | 390 | The name of a preset defined in an outer `Typesetting.Provider` 391 | component. If it exists, default values for all other props will come 392 | from the specified preset. 393 | 394 |
398 | 399 | ### Justify 400 | 401 | ```js 402 | import { Justify } from 'react-typesetting'; 403 | ``` 404 | 405 | While this may include more advanced justification features in the future, it 406 | is currently very simple: it conditionally applies `text-align: justify` to 407 | its container element (a `

` by default) depending on whether or not there 408 | is enough room to avoid large, unseemly word gaps. The minimum width is 409 | defined by `minWidth` and defaults to 16 ems. 410 | 411 | You might also accomplish this with media queries, but this component can 412 | determine the exact width available to the container element instead of just 413 | the entire page. 414 | 415 | #### Props 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 437 | 438 | 439 | 440 | 441 | 442 | 448 | 449 | 450 | 451 | 452 | 453 | 458 | 459 | 460 | 461 | 467 | 468 | 476 | 477 | 478 | 479 | 484 | 485 | 492 | 493 | 494 | 495 | 496 | 497 | 503 | 504 | 505 | 506 | 511 | 512 | 519 | 520 | 521 | 522 | 523 | 524 | 531 | 532 | 533 | 534 | 535 | 536 | 542 | 543 | 544 | 545 | 546 | 547 | 553 | 554 | 555 | 556 | 557 | 558 | 565 | 566 | 567 |
NameTypeDefaultDescription
classNameString 432 | 433 | The class to apply to the outer wrapper element created by this 434 | component. 435 | 436 |
styleObject 443 | 444 | Extra style properties to add to the outer wrapper element created by 445 | this component. 446 | 447 |
childrenNode 454 | 455 | The content to render. 456 | 457 |
as 462 | One of…
463 |   String
464 |   Function
465 |   Object 466 |
p 469 | 470 | The element type in which to render the supplied children. It must be 471 | a block level element, like `p` or `div`, since `text-align` has no 472 | effect on inline elements. It may also be a custom React component, as 473 | long as it uses `forwardRef`. 474 | 475 |
minWidth 480 | One of…
481 |   Number
482 |   String 483 |
16em 486 | 487 | The minimum width at which to allow justified text. Numbers indicate an 488 | absolute pixel width. Strings will be applied to an element's CSS in 489 | order to perform the width calculation. 490 | 491 |
initialJustifyBooleantrue 498 | 499 | Whether or not to initially set `text-align: justify` before the 500 | available width has been determined. 501 | 502 |
reflowKey 507 | One of…
508 |   Number
509 |   String 510 |
513 | 514 | If specified, disables automatic reflow so that you can trigger it 515 | manually by changing this value. The prop itself does nothing, but 516 | changing it will cause React to update the component. 517 | 518 |
reflowTimeoutNumber 525 | 526 | Debounces reflows so they happen at most this often in milliseconds (at 527 | the end of the given duration). If not specified, reflow is computed 528 | every time the component is rendered. 529 | 530 |
disabledBoolean 537 | 538 | Whether to completely disable justification detection. The last 539 | alignment that was applied will be preserved. 540 | 541 |
onReflowFunction 548 | 549 | A function to call when layout has been recomputed and justification 550 | has been applied or unapplied. 551 | 552 |
presetString 559 | 560 | The name of a preset defined in an outer `Typesetting.Provider` 561 | component. If it exists, default values for all other props will come 562 | from the specified preset. 563 | 564 |
568 | 569 | ### FontObserver 570 | 571 | ```js 572 | import { FontObserver } from 'react-typesetting'; 573 | ``` 574 | 575 | A component for observing the fonts specified in the `FontObserver.Provider` 576 | component. 577 | 578 | #### Props 579 | 580 | 581 | 582 | 583 | 584 | 585 | 586 | 587 | 588 | 589 | 590 | 591 | 592 | 593 | 594 | 606 | 607 | 608 |
NameTypeDefaultDescription
childrenFunction 595 | 596 | A function that will receive the current status of the observed fonts. 597 | The argument will be an object with these properties: 598 | 599 | - `fonts`: An array of the fonts passed to `FontObserver.Provider`, each 600 | with a `loaded` and `error` property. 601 | - `loaded`: Whether all observed fonts are done loading. 602 | - `error`: If any fonts failed to load, this will be populated with the 603 | first error. 604 | 605 |
609 | 610 | ### FontObserver.Provider 611 | 612 | ```js 613 | import { FontObserver } from 'react-typesetting'; 614 | ``` 615 | 616 | A context provider for specifying which fonts to observe. 617 | 618 | #### Props 619 | 620 | 621 | 622 | 623 | 624 | 625 | 626 | 627 | 628 | 629 | 630 | 631 | 632 | 638 | 639 | 652 | 653 | 654 | 655 | 658 | 659 | 660 | 661 | 662 | 663 | 664 | 665 | 666 | 672 | 673 | 674 | 675 | 676 | 677 | 684 | 685 | 686 | 687 | 688 | 689 | 694 | 695 | 696 |
NameTypeDefaultDescription
fonts 633 | Array of…
634 |   One of…
635 |     String
636 |     Object 1 637 |
640 | 641 | The fonts to load and observe. The font files themselves should already 642 | be specified somewhere (in CSS), this component simply uses `FontFaceObserver` 643 | to force them to load (if necessary) and observe when they are ready. 644 | 645 | Each item in the array specifies the font `family`, `weight`, `style`, 646 | and `stretch`, with only `family` being required. Additionally, each item 647 | can contain a custom `testString` and `timeout` for that font, if they 648 | should differ from the defaults. If only the family name is needed, the 649 | array item can just be a string. 650 | 651 |
1 Object
familyString
weightOne of…
656 |   Number
657 |   String
styleString
stretchString
testStringString
timeoutNumber
testStringString 667 | 668 | A custom test string to pass to the `load` method of `FontFaceObserver`, 669 | to be used for all fonts that do not specify their own `testString`. 670 | 671 |
timeoutNumber 678 | 679 | A custom timeout in milliseconds to pass to the `load` method of 680 | `FontFaceObserver`, to be used for all fonts that do not specify their 681 | own `timeout`. 682 | 683 |
childrenNode 690 | 691 | The content that will have access to font loading status via context. 692 | 693 |
697 | 698 | ### Typesetting.Provider 699 | 700 | ```js 701 | import { Typesetting } from 'react-typesetting'; 702 | ``` 703 | 704 | A context provider for defining presets for all other `react-typesetting` 705 | components to use. 706 | 707 | #### Props 708 | 709 | 710 | 711 | 712 | 713 | 714 | 715 | 716 | 717 | 718 | 719 | 720 | 721 | 722 | 723 | 740 | 741 | 742 | 743 | 744 | 745 | 750 | 751 | 752 |
NameTypeDefaultDescription
presetsObject{} 724 | 725 | An object mapping preset names to default props. For example, given the 726 | value: 727 | 728 | ```js 729 | { myPreset: { minFontSize: 1, maxIterations: 3 } } 730 | ``` 731 | 732 | …the `TightenText` component could use this preset by specifying the 733 | `preset` prop: 734 | 735 | ```jsx 736 | 737 | ``` 738 | 739 |
childrenNode 746 | 747 | The content that will have access to the defined presets via context. 748 | 749 |
753 | 754 | -------------------------------------------------------------------------------- /demo/Resizable.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { DraggableCore } from "react-draggable"; 4 | import styled from "styled-components"; 5 | 6 | const noop = () => {}; 7 | 8 | const ResizableContent = styled.div` 9 | position: relative; 10 | margin-top: 40px; 11 | `; 12 | 13 | const Hint = styled.div` 14 | position: absolute; 15 | left: -5.5em; 16 | right: -0.5em; 17 | bottom: 100%; 18 | margin: 0 auto 8px auto; 19 | font-family: Courgette, sans-serif; 20 | font-size: 14px; 21 | font-weight: normal; 22 | font-style: normal; 23 | line-height: 1; 24 | color: #1289de; 25 | text-align: center; 26 | pointer-events: none; 27 | opacity: ${props => (props.hasBeenDragged ? 0 : 1)}; 28 | transform: scale(${props => (props.hasBeenDragged ? 0.8 : 1)}); 29 | transition-property: transform, opacity; 30 | transition-duration: 0.5s; 31 | 32 | @media (min-width: 768px) { 33 | left: -3em; 34 | right: -3em; 35 | } 36 | `; 37 | 38 | const Handle = styled.div` 39 | position: absolute; 40 | top: -1px; 41 | right: -19px; 42 | bottom: -1px; 43 | width: 31px; 44 | background: transparent; 45 | cursor: ew-resize; 46 | user-select: none; 47 | z-index: 2; 48 | 49 | @media (max-width: 767px) { 50 | top: -4px; 51 | right: -24px; 52 | bottom: -4px; 53 | width: 39px; 54 | } 55 | 56 | &:before { 57 | display: block; 58 | position: absolute; 59 | top: 0; 60 | left: 12px; 61 | bottom: 0; 62 | width: 7px; 63 | border-radius: 2px; 64 | background: #1289de; 65 | content: ""; 66 | 67 | @media (max-width: 767px) { 68 | left: 15px; 69 | width: 9px; 70 | } 71 | } 72 | 73 | &:after { 74 | display: block; 75 | position: absolute; 76 | top: 50%; 77 | left: 15px; 78 | width: 1px; 79 | height: 12px; 80 | margin-top: -6px; 81 | background: rgba(179, 241, 255, 0.85); 82 | content: ""; 83 | 84 | @media (max-width: 767px) { 85 | left: 19px; 86 | height: 18px; 87 | margin-top: -9px; 88 | } 89 | } 90 | `; 91 | 92 | export const ResizableHandle = ({ title, hint, hasBeenDragged, ...props }) => ( 93 | 94 | 95 | {hint ? {hint} : null} 96 | 97 | 98 | ); 99 | 100 | export default class Resizable extends React.PureComponent { 101 | static propTypes = { 102 | className: PropTypes.string, 103 | initialWidth: PropTypes.number, 104 | onStart: PropTypes.func, 105 | onStop: PropTypes.func, 106 | onResize: PropTypes.func, 107 | style: PropTypes.object, 108 | children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]) 109 | }; 110 | 111 | static defaultProps = { 112 | style: {} 113 | }; 114 | 115 | state = { 116 | attemptedWidth: this.props.initialWidth, 117 | dragged: false 118 | }; 119 | 120 | hostRef = React.createRef(); 121 | 122 | getActualWidth() { 123 | const node = this.hostRef.current; 124 | return parseFloat(window.getComputedStyle(node).width); 125 | } 126 | 127 | handleStart = () => { 128 | const actualWidth = this.getActualWidth(); 129 | if (this.props.onStart) { 130 | this.props.onStart({ width: actualWidth }); 131 | } 132 | this.setState({ 133 | attemptedWidth: actualWidth 134 | }); 135 | }; 136 | 137 | handleStop = () => { 138 | const actualWidth = this.getActualWidth(); 139 | if (this.props.onStop) { 140 | this.props.onStop({ width: actualWidth }); 141 | } 142 | this.setState({ attemptedWidth: actualWidth }); 143 | }; 144 | 145 | handleDrag = (event, data) => { 146 | this.setState(state => ({ 147 | attemptedWidth: state.attemptedWidth + data.deltaX, 148 | dragged: true 149 | })); 150 | }; 151 | 152 | componentDidMount() { 153 | // Adding this empty event listener fixes the window scrolling while 154 | // a handle is being dragged. 155 | window.addEventListener("touchmove", noop); 156 | this.actualWidth = this.getActualWidth(); 157 | } 158 | 159 | componentDidUpdate(prevProps, prevState) { 160 | const actualWidth = this.getActualWidth(); 161 | if (actualWidth !== this.actualWidth) { 162 | if (actualWidth != null && this.props.onResize) { 163 | this.props.onResize({ 164 | width: actualWidth, 165 | deltaX: actualWidth - this.actualWidth 166 | }); 167 | this.actualWidth = actualWidth; 168 | } 169 | } 170 | } 171 | 172 | componentWillUnmount() { 173 | window.removeEventListener("touchmove", noop); 174 | } 175 | 176 | render() { 177 | const { className, style, children } = this.props; 178 | const { attemptedWidth, dragged } = this.state; 179 | return ( 180 | 185 | 193 | {typeof children === "function" ? children(attemptedWidth) : children} 194 | 195 | ); 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exogen/react-typesetting/7114cdc8c4cb1b0d59ebc8b5364e808687419889/docs/.nojekyll -------------------------------------------------------------------------------- /docs/_next/static/RdOs3pPMQa1yRsOzgnCk3/pages/_app.js: -------------------------------------------------------------------------------- 1 | (window.webpackJsonp=window.webpackJsonp||[]).push([[4],{233:function(e,t,n){__NEXT_REGISTER_PAGE("/_app",function(){return e.exports=n(234),{page:e.exports.default}})},234:function(e,t,n){e.exports=n(235)},235:function(e,t,n){e.exports=n(236)},236:function(e,t,n){"use strict";var r=n(26),u=n(4);Object.defineProperty(t,"__esModule",{value:!0}),t.createUrl=x,t.Container=t.default=void 0;var a=u(n(45)),o=u(n(46)),i=u(n(237)),l=u(n(8)),c=u(n(9)),p=u(n(23)),s=u(n(24)),f=u(n(25)),d=u(n(17)),h=r(n(0)),v=u(n(6)),y=n(31),m=n(72),g=function(e){function t(){return(0,l.default)(this,t),(0,p.default)(this,(0,s.default)(t).apply(this,arguments))}var n;return(0,f.default)(t,e),(0,c.default)(t,[{key:"getChildContext",value:function(){return{headManager:this.props.headManager,router:(0,m.makePublicRouterInstance)(this.props.router)}}},{key:"componentDidCatch",value:function(e){throw e}},{key:"render",value:function(){var e=this.props,t=e.router,n=e.Component,r=e.pageProps,u=x(t);return h.default.createElement(k,null,h.default.createElement(n,(0,i.default)({},r,{url:u})))}}],[{key:"getInitialProps",value:(n=(0,o.default)(a.default.mark(function e(t){var n,r,u;return a.default.wrap(function(e){for(;;)switch(e.prev=e.next){case 0:return n=t.Component,t.router,r=t.ctx,e.next=3,(0,y.loadGetInitialProps)(n,r);case 3:return u=e.sent,e.abrupt("return",{pageProps:u});case 5:case"end":return e.stop()}},e,this)})),function(e){return n.apply(this,arguments)})}]),t}(h.Component);t.default=g,(0,d.default)(g,"childContextTypes",{headManager:v.default.object,router:v.default.object});var k=function(e){function t(){return(0,l.default)(this,t),(0,p.default)(this,(0,s.default)(t).apply(this,arguments))}return(0,f.default)(t,e),(0,c.default)(t,[{key:"componentDidMount",value:function(){this.scrollToHash()}},{key:"componentDidUpdate",value:function(){this.scrollToHash()}},{key:"scrollToHash",value:function(){var e=window.location.hash;if(e=!!e&&e.substring(1)){var t=document.getElementById(e);t&&setTimeout(function(){return t.scrollIntoView()},0)}}},{key:"render",value:function(){return this.props.children}}]),t}(h.Component);t.Container=k;var w=(0,y.execOnce)(function(){0});function x(e){var t=e.pathname,n=e.asPath,r=e.query;return{get query(){return w(),r},get pathname(){return w(),t},get asPath(){return w(),n},back:function(){w(),e.back()},push:function(t,n){return w(),e.push(t,n)},pushTo:function(t,n){w();var r=n?t:null,u=n||t;return e.push(r,u)},replace:function(t,n){return w(),e.replace(t,n)},replaceTo:function(t,n){w();var r=n?t:null,u=n||t;return e.replace(r,u)}}}},237:function(e,t,n){var r=n(76);function u(){return e.exports=u=r||function(e){for(var t=1;t0&&void 0!==a[0]?a[0]:{},r.webpackHMR,e.next=4,I.loadPage("/_error");case 4:return t.ErrorComponent=N=e.sent,e.next=7,I.loadPage("/_app");case 7:return q=e.sent,n=C,e.prev=9,e.next=12,I.loadPage(b);case 12:if("function"==typeof(j=e.sent)){e.next=15;break}throw new Error('The default export is not a React Component in page: "'.concat(k,'"'));case 15:e.next=20;break;case 17:e.prev=17,e.t0=e.catch(9),n=e.t0;case 20:return e.next=22,w.default.preloadReady(T||[]);case 22:return t.router=L=(0,p.createRouter)(k,P,S,{initialProps:x,pageLoader:I,App:q,Component:j,ErrorComponent:N,err:n}),L.subscribe(function(e){U({App:e.App,Component:e.Component,props:e.props,err:e.err,emitter:X})}),U({App:q,Component:j,props:x,err:n,emitter:X}),e.abrupt("return",X);case 26:case"end":return e.stop()}},e,this,[[9,17]])}));function U(e){return B.apply(this,arguments)}function B(){return(B=(0,i.default)(u.default.mark(function e(t){return u.default.wrap(function(e){for(;;)switch(e.prev=e.next){case 0:if(!t.err){e.next=4;break}return e.next=3,J(t);case 3:return e.abrupt("return");case 4:return e.prev=4,e.next=7,F(t);case 7:e.next=13;break;case 9:return e.prev=9,e.t0=e.catch(4),e.next=13,J((0,o.default)({},t,{err:e.t0}));case 13:case"end":return e.stop()}},e,this,[[4,9]])}))).apply(this,arguments)}function J(e){return W.apply(this,arguments)}function W(){return(W=(0,i.default)(u.default.mark(function e(t){var r,n,a;return u.default.wrap(function(e){for(;;)switch(e.prev=e.next){case 0:r=t.App,n=t.err,e.next=3;break;case 3:if(console.error(n),!t.props){e.next=8;break}e.t0=t.props,e.next=11;break;case 8:return e.next=10,(0,m.loadGetInitialProps)(r,{Component:N,router:L,ctx:{err:n,pathname:k,query:P,asPath:S}});case 10:e.t0=e.sent;case 11:return a=e.t0,e.next=14,F((0,o.default)({},t,{err:n,Component:N,props:a}));case 14:case"end":return e.stop()}},e,this)}))).apply(this,arguments)}t.default=z;var $=!0;function F(e){return K.apply(this,arguments)}function K(){return(K=(0,i.default)(u.default.mark(function e(t){var r,n,a,l,c,f,p,h,v,g,y,w;return u.default.wrap(function(e){for(;;)switch(e.prev=e.next){case 0:if(r=t.App,n=t.Component,a=t.props,l=t.err,c=t.emitter,f=void 0===c?X:c,a||!n||n===N||D.Component!==N){e.next=6;break}return h=(p=L).pathname,v=p.query,g=p.asPath,e.next=5,(0,m.loadGetInitialProps)(r,{Component:n,router:L,ctx:{err:l,pathname:h,query:v,asPath:g}});case 5:a=e.sent;case 6:n=n||D.Component,a=a||D.props,y=(0,o.default)({Component:n,err:l,router:L,headManager:G},a),D=y,f.emit("before-reactdom-render",{Component:n,ErrorComponent:N,appProps:y}),w=function(){var e=(0,i.default)(u.default.mark(function e(t){return u.default.wrap(function(e){for(;;)switch(e.prev=e.next){case 0:return e.prev=0,e.next=3,J({App:r,err:t});case 3:e.next=8;break;case 5:e.prev=5,e.t0=e.catch(0),console.error("Error while rendering error page: ",e.t0);case 8:case"end":return e.stop()}},e,this,[[0,5]])}));return function(t){return e.apply(this,arguments)}}(),E=s.default.createElement(_.default,{onError:w},s.default.createElement(r,y)),x=H,$&&"function"==typeof d.default.hydrate?(d.default.hydrate(E,x),$=!1):d.default.render(E,x),f.emit("after-reactdom-render",{Component:n,ErrorComponent:N,appProps:y});case 13:case"end":return e.stop()}var E,x},e,this)}))).apply(this,arguments)}},159:function(e,t,r){"use strict";var n=r(4);Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0;var a=n(r(28)),o=n(r(8)),u=n(r(9)),i={acceptCharset:"accept-charset",className:"class",htmlFor:"for",httpEquiv:"http-equiv"},l=function(){function e(){(0,o.default)(this,e),this.updatePromise=null}return(0,u.default)(e,[{key:"updateHead",value:function(e){var t=this,r=this.updatePromise=a.default.resolve().then(function(){r===t.updatePromise&&(t.updatePromise=null,t.doUpdateHead(e))})}},{key:"doUpdateHead",value:function(e){var t=this,r={};e.forEach(function(e){var t=r[e.type]||[];t.push(e),r[e.type]=t}),this.updateTitle(r.title?r.title[0]:null);["meta","base","link","style","script"].forEach(function(e){t.updateElements(e,r[e]||[])})}},{key:"updateTitle",value:function(e){var t;if(e){var r=e.props.children;t="string"==typeof r?r:r.join("")}else t="";t!==document.title&&(document.title=t)}},{key:"updateElements",value:function(e,t){var r=document.getElementsByTagName("head")[0],n=Array.prototype.slice.call(r.querySelectorAll(e+".next-head")),a=t.map(c).filter(function(e){for(var t=0,r=n.length;t0?arguments[0]:void 0)}},{get:function(e){var t=n.getEntry(a(this,"Map"),e);return t&&t.v},set:function(e,t){return n.def(a(this,"Map"),0===e?0:e,t)}},n,!0)},213:function(e,t,r){var n=r(2);n(n.P+n.R,"Map",{toJSON:r(106)("Map")})},214:function(e,t,r){r(107)("Map")},215:function(e,t,r){r(108)("Map")}},[[120,1,0]]]); -------------------------------------------------------------------------------- /docs/_next/static/runtime/webpack-42652fa8b82c329c0559.js: -------------------------------------------------------------------------------- 1 | !function(e){function r(r){for(var n,l,f=r[0],i=r[1],a=r[2],c=0,s=[];creact-typesetting ❧ React components for creating beautifully typeset designs

react-typesetting

This page is a demonstration of react-typesetting, a collection of React components for projects that emphasize text-heavy design.

<TightenText>

The TightenText component is intended to give short runs of text (like titles, labels, etc.) some “give” before wrapping. This is useful when you want to prioritize having fewer lines of text over having completely rigid visual tightness.

When a line is just slightly too long for the available space, the text will be set tighter by a barely perceptible amount to avoid wrapping. By default, adjustments are made to word spacing, letter spacing, and font size (preferentially in that order).

Try dragging to adjust the available space for this line from a cocktail recipe:

Drag to resize!
Islay single malt Scotch whisky

Notice that the text resists both wrapping (when a new line would be formed) and overflowing (when the words can’t be broken any more) – up to a certain point.

<PreventWidows>

Although the terminology varies, “widows” often refer to very short lines of text at the end of paragraphs. This tends to be undesirable during typesetting, as it gives the appearance of too much whitespace between the paragraph and any elements that follow, and can be distracting. It is generally preferable to find a way to either remove the extra line (a la TightenText) or make it longer. If possible, you can even just opt to reword your writing.

Many HTML typesetting helpers implement this in a naïve way – for example, by always joining the last word with a non-breaking space. This gives poor results, since it does not account for how long the last line actually is. Try the naïve approach to see how it fails to achieve the desired wrapping:

Drag to resize!
The Long Goodbye

The PreventWidows component instead works by actually measuring the widths of lines, and can thus support many different ways to specify the desired minimum width – including percentages, pixels, and ems. The default minimum is 15% of the available width.

In this demo, a custom nbspChar element is supplied that highlights the inserted spaces for demonstration purposes:

Drag to resize!
Call me Ishmael. Some years ago—never mind how long precisely—having little or no money in my purse, and nothing particular to interest me on shore, I thought I would sail about a little and see the watery part of the world. It is a way I…

The current strategy works especially well with justified text, since there is no rag on the preceding line to worry about:

Drag to resize!

One morning, when Gregor Samsa woke from troubled dreams, he found himself transformed in his bed into a horrible vermin. He lay on his armour-like back, and if he lifted his head a little he could see his brown belly, slightly domed and divided by arches into stiff sections. The bedding was hardly able to cover it and seemed ready to slide off any moment. His many legs, pitifully thin compared with the size of the rest of him, waved about helplessly as he looked.

<Justify>

Sometimes you want to render justified text, but due to changing element sizes in a responsive design, it could make things worse. Justified text tends to look bad in narrow columns of text, because it forces very large spaces between the words. For example:

Drag to resize!

There was no possibility of taking a walk that day. We had been wandering, indeed, in the leafless shrubbery an hour in the morning; but since dinner (Mrs. Reed, when there was no company, dined early) the cold winter wind had brought with it clouds so sombre, and a rain so penetrating, that further out-door exercise was now out of the question.

The Justify component solves this by conditionally setting text as justified only when there is enough room, and otherwise the text will inherit its alignment as normal. Here is the same paragraph as above, but using conditional justification. Try making it wider:

Drag to resize!

There was no possibility of taking a walk that day. We had been wandering, indeed, in the leafless shrubbery an hour in the morning; but since dinner (Mrs. Reed, when there was no company, dined early) the cold winter wind had brought with it clouds so sombre, and a rain so penetrating, that further out-door exercise was now out of the question.

<FontObserver>

When creating pages with typographically important features, sometimes you’ll want to know when your custom fonts are done loading. Perhaps you’ve done some rendering calculations that are influenced by font metrics (like how wide a line of text is) and thus need to recompute them when your font is shown? The components above are great examples of this.

The FontObserver component offers an interface to this information. By supplying FontObserver.Provider a list of fonts to observe, it will use Font Face Observer to populate a React context provider with status information for each font. You can then use FontObserver anywhere in the subtree to get updates.

Below is a list of the fonts used on this page, rendered using data from FontObserver. The symbol rendered next to each font is based on the loaded and error properties that are populated for each font.

  • Courgette
  • Libre Baskerville • 400
  • Libre Baskerville • 400 • italic
  • Libre Baskerville • 700

Documentation

All options are documented in the README.

-------------------------------------------------------------------------------- /markdown.config.js: -------------------------------------------------------------------------------- 1 | const execa = require("execa"); 2 | 3 | module.exports = { 4 | transforms: { 5 | COMPONENTS(content, options) { 6 | const json = execa.sync("scripts/extract-docs.js").stdout; 7 | const markdown = execa.sync("scripts/markdown-docs.js", { 8 | input: json 9 | }).stdout; 10 | return markdown; 11 | } 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | assetPrefix: ".", 3 | exportPathMap: () => ({ 4 | "/": { page: "/" } 5 | }) 6 | }; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-typesetting", 3 | "version": "0.3.1", 4 | "main": "dist-cjs/index.js", 5 | "author": "Brian Beck ", 6 | "license": "MIT", 7 | "files": [ 8 | "dist-cjs", 9 | "dist-esm" 10 | ], 11 | "module": "dist-esm/index.js", 12 | "scripts": { 13 | "build": "npm run build:demo && npm run build:dist && npm run build:docs", 14 | "build:demo": "rimraf .next docs && next build && next export -o docs && touch docs/.nojekyll", 15 | "build:dist": "npm run build:dist-esm && npm run build:dist-cjs", 16 | "build:dist-cjs": "rimraf dist-cjs && BABEL_ENV=cjs babel src -d dist-cjs", 17 | "build:dist-esm": "rimraf dist-esm && BABEL_ENV=esm babel src -d dist-esm", 18 | "build:docs": "md-magic README.md", 19 | "format": "npm run lint -- --fix", 20 | "lint": "eslint demo pages scripts src *.js", 21 | "prepare": "npm run build:dist", 22 | "start": "next start", 23 | "start:dev": "next dev", 24 | "test": "npm run lint" 25 | }, 26 | "husky": { 27 | "hooks": { 28 | "pre-commit": "npm run lint" 29 | } 30 | }, 31 | "peerDependencies": { 32 | "react": "^16.3.0", 33 | "react-dom": "^16.3.0" 34 | }, 35 | "dependencies": { 36 | "debug": "^4.0.1", 37 | "fontfaceobserver": "^2.0.13", 38 | "prop-types": "^15.6.2", 39 | "resize-observer-polyfill": "^1.5.0" 40 | }, 41 | "devDependencies": { 42 | "@babel/cli": "^7.1.2", 43 | "@babel/core": "^7.1.2", 44 | "babel-eslint": "^10.0.1", 45 | "babel-plugin-styled-components": "^1.8.0", 46 | "eslint": "^5.6.1", 47 | "eslint-config-prettier": "^3.1.0", 48 | "eslint-config-standard": "^12.0.0", 49 | "eslint-config-standard-react": "^7.0.2", 50 | "eslint-plugin-import": ">=2.13.0", 51 | "eslint-plugin-node": ">=7.0.0", 52 | "eslint-plugin-prettier": "^3.0.0", 53 | "eslint-plugin-promise": ">=4.0.0", 54 | "eslint-plugin-react": "^7.11.1", 55 | "eslint-plugin-standard": ">=4.0.0", 56 | "execa": "^1.0.0", 57 | "husky": "^1.1.1", 58 | "jest": "^23.6.0", 59 | "markdown-magic": "^0.1.25", 60 | "next": "^7.0.1", 61 | "prettier": "^1.14.3", 62 | "react": "^16.5.2", 63 | "react-docgen": "^2.21.0", 64 | "react-docgen-displayname-handler": "^2.1.1", 65 | "react-dom": "^16.5.2", 66 | "react-draggable": "^3.0.5", 67 | "react-responsive": "^5.0.0", 68 | "react-testing-library": "^5.2.0", 69 | "rimraf": "^2.6.2", 70 | "styled-components": "^4.0.0-beta.10", 71 | "webpack": "^4.20.2" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /pages/_document.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import NextDocument, { Head, Main, NextScript } from "next/document"; 3 | import { ServerStyleSheet } from "styled-components"; 4 | 5 | export default class Document extends NextDocument { 6 | static getInitialProps({ renderPage }) { 7 | const sheet = new ServerStyleSheet(); 8 | const page = renderPage(App => props => 9 | sheet.collectStyles() 10 | ); 11 | const styleTags = sheet.getStyleElement(); 12 | return { ...page, styleTags }; 13 | } 14 | 15 | render() { 16 | return ( 17 | 18 | 19 | 20 | 24 | {this.props.styleTags} 25 | 26 | 27 |
28 | 29 | 30 | 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Head from "next/head"; 3 | import styled, { createGlobalStyle } from "styled-components"; 4 | import MediaQuery from "react-responsive"; 5 | import Resizable from "../demo/Resizable"; 6 | import { TightenText, PreventWidows, Justify, FontObserver } from "../src"; 7 | 8 | const VisibleSpace = styled.span.attrs({ 9 | children: props => "\u00a0", 10 | title: "non-breaking space inserted by " 11 | })` 12 | background: rgb(255, 230, 3); 13 | `; 14 | 15 | const GlobalStyle = createGlobalStyle` 16 | html { 17 | font-size: 14px; 18 | overflow-x: hidden; 19 | 20 | @media (min-width: 768px) { 21 | font-size: 16px; 22 | } 23 | } 24 | 25 | body { 26 | margin: 0; 27 | padding: 20px; 28 | font-family: 'Libre Baskerville', Georgia, serif; 29 | font-size: 1rem; 30 | line-height: 1.7; 31 | text-rendering: optimizeLegibility; 32 | background: rgb(232, 230, 224); 33 | color: rgb(52, 50, 47); 34 | overflow-x: hidden; 35 | 36 | @media (min-width: 768px) { 37 | padding: 50px 100px; 38 | } 39 | } 40 | 41 | code { 42 | font-family: Menlo, Monaco, Consolas, 'Source Sans Pro', monospace; 43 | padding: 2px 4px; 44 | border-radius: 2px; 45 | background: rgba(74, 65, 59, 0.1); 46 | color: rgb(66, 64, 60); 47 | } 48 | 49 | abbr { 50 | font-size: 0.9em; 51 | letter-spacing: -0.01em; 52 | } 53 | 54 | a:link { 55 | color: rgb(0, 70, 162); 56 | } 57 | 58 | a:visited { 59 | color: rgb(54, 36, 140); 60 | } 61 | `; 62 | 63 | const MainContent = styled.main` 64 | max-width: 60ch; 65 | `; 66 | 67 | const PageTitle = styled.h1` 68 | font-size: 1.8rem; 69 | font-weight: 700; 70 | letter-spacing: -0.02em; 71 | `; 72 | 73 | const SectionTitle = styled.h2` 74 | font-size: 1.5rem; 75 | font-weight: normal; 76 | `; 77 | 78 | const DemoResizable = styled(Resizable)` 79 | font-size: ${18 / 16}rem; 80 | line-height: 1.4; 81 | margin-bottom: 40px; 82 | padding-top: 0.25em; 83 | padding-bottom: 0.25em; 84 | background: #fff; 85 | 86 | @media (max-width: 767px) { 87 | max-width: 100%; 88 | } 89 | `; 90 | 91 | function ReflowResizable({ children, ...props }) { 92 | return ( 93 | 94 | {width => ( 95 | 96 | {isDesktop => ( 97 | 98 | {status => 99 | typeof children === "function" 100 | ? children( 101 | `${width}-${isDesktop}-${status.loaded}-${status.error}` 102 | ) 103 | : children 104 | } 105 | 106 | )} 107 | 108 | )} 109 | 110 | ); 111 | } 112 | 113 | export default class App extends React.Component { 114 | render() { 115 | return ( 116 | <> 117 | 118 | 119 | 120 | react-typesetting ❧ React components for creating beautifully 121 | typeset designs 122 | 123 | 124 | 132 | 133 | react-typesetting 134 | 135 | 136 | 137 | This page is a demonstration of{" "} 138 | 143 | react-typesetting 144 | 145 | , a collection of React components for projects that emphasize 146 | text-heavy design. 147 | 148 | 149 | 150 |
151 | <TightenText> 152 | 153 | 154 | 155 | The TightenText component is intended to give 156 | short runs of text (like titles, labels, etc.) some “give” 157 | before wrapping. This is useful when you want to prioritize 158 | having fewer lines of text over having completely rigid visual 159 | tightness. 160 | 161 | 162 | 163 | 164 | When a line is just slightly too long for the available space, 165 | the text will be set tighter by a barely perceptible amount to 166 | avoid wrapping. By default, adjustments are made to word 167 | spacing, letter spacing, and font size (preferentially in that 168 | order). 169 | 170 | 171 | 172 | 173 | Try dragging to adjust the available space for this line from 174 | a cocktail recipe: 175 | 176 | 177 | 178 | 179 | {reflowKey => ( 180 | 181 | Islay single malt Scotch whisky 182 | 183 | )} 184 | 185 | 186 | 187 | 188 | Notice that the text resists both wrapping (when a new line 189 | would be formed) and overflowing (when the words can’t be 190 | broken any more) – up to a certain point. 191 | 192 | 193 |
194 | 195 |
196 | <PreventWidows> 197 | 198 | 199 | 200 | Although the terminology varies, “widows” often refer to very 201 | short lines of text at the end of paragraphs. This tends to be 202 | undesirable during typesetting, as it gives the appearance of 203 | too much whitespace between the paragraph and any elements 204 | that follow, and can be distracting. It is generally 205 | preferable to find a way to either remove the extra line ( 206 | a la TightenText) or make it longer. If 207 | possible, you can even just opt to reword your writing. 208 | 209 | 210 | 211 | 212 | 213 | Many HTML typesetting helpers implement this in a 214 | naïve way – for example, by always joining the last word with 215 | a{" "} 216 | 221 | non-breaking space 222 | 223 | . This gives poor results, since it does not account for how 224 | long the last line actually is. Try the naïve approach to see 225 | how it fails to achieve the desired wrapping: 226 | 227 | 228 | 229 | 230 | The Long Goodbye 231 | 232 | 233 | 234 | 235 | The PreventWidows component instead works by 236 | actually measuring the widths of lines, and can thus support 237 | many different ways to specify the desired minimum width – 238 | including percentages, pixels, and ems. The default minimum is 239 | 15% of the available width. 240 | 241 | 242 | 243 | 244 | 245 | In this demo, a custom nbspChar element is 246 | supplied that highlights the inserted spaces for demonstration 247 | purposes: 248 | 249 | 250 | 251 | 252 | {reflowKey => ( 253 | } 255 | reflowKey={reflowKey} 256 | > 257 | Call me Ishmael. Some years ago—never mind how long 258 | precisely—having little or no money in my purse, and nothing 259 | particular to interest me on shore, I thought I would sail 260 | about a little and see the watery part of the world. It is a 261 | way I… 262 | 263 | )} 264 | 265 |
266 | 267 | 268 | 269 | The current strategy works especially well with justified text, 270 | since there is no rag on the preceding line to worry about: 271 | 272 | 273 | 274 | 275 | {reflowKey => ( 276 |

277 | } 279 | reflowKey={reflowKey} 280 | > 281 | One morning, when Gregor Samsa woke from troubled dreams, he 282 | found himself transformed in his bed into a horrible vermin. 283 | He lay on his armour-like back, and if he lifted his head a 284 | little he could see his brown belly, slightly domed and 285 | divided by arches into stiff sections. The bedding was 286 | hardly able to cover it and seemed ready to slide off any 287 | moment. His many legs, pitifully thin compared with the size 288 | of the rest of him, waved about helplessly as he looked. 289 | 290 |

291 | )} 292 |
293 | 294 |
295 | <Justify> 296 | 297 | 298 | 299 | Sometimes you want to render justified text, but due to 300 | changing element sizes in a responsive design, it could make 301 | things worse. Justified text tends to look bad in narrow 302 | columns of text, because it forces very large spaces between 303 | the words. For example: 304 | 305 | 306 | 307 | 308 |

309 | There was no possibility of taking a walk that day. We had 310 | been wandering, indeed, in the leafless shrubbery an hour in 311 | the morning; but since dinner (Mrs. Reed, when there was no 312 | company, dined early) the cold winter wind had brought with it 313 | clouds so sombre, and a rain so penetrating, that further 314 | out-door exercise was now out of the question. 315 |

316 |
317 | 318 | 319 | 320 | The Justify component solves this by 321 | conditionally setting text as justified only when there is 322 | enough room, and otherwise the text will inherit its alignment 323 | as normal. Here is the same paragraph as above, but using 324 | conditional justification. Try making it wider: 325 | 326 | 327 | 328 | 329 | {reflowKey => ( 330 | 331 | There was no possibility of taking a walk that day. We had 332 | been wandering, indeed, in the leafless shrubbery an hour in 333 | the morning; but since dinner (Mrs. Reed, when there was no 334 | company, dined early) the cold winter wind had brought with 335 | it clouds so sombre, and a rain so penetrating, that further 336 | out-door exercise was now out of the question. 337 | 338 | )} 339 | 340 |
341 | 342 |
343 | <FontObserver> 344 | 345 | 346 | 347 | When creating pages with typographically important features, 348 | sometimes you’ll want to know when your custom fonts are done 349 | loading. Perhaps you’ve done some rendering calculations that 350 | are influenced by font metrics (like how wide a line of text 351 | is) and thus need to recompute them when your font is shown? 352 | The components above are great examples of this. 353 | 354 | 355 | 356 | 357 | The FontObserver component offers an interface to 358 | this information. By supplying{" "} 359 | FontObserver.Provider a list of fonts to observe, 360 | it will use{" "} 361 | 366 | Font Face Observer 367 | {" "} 368 | to populate a React context provider with status information 369 | for each font. You can then use FontObserver{" "} 370 | anywhere in the subtree to get updates. 371 | 372 | 373 | 374 | 375 | 376 | Below is a list of the fonts used on this page, rendered using 377 | data from FontObserver. The symbol rendered next 378 | to each font is based on the loaded and{" "} 379 | error properties that are populated for each 380 | font. 381 | 382 | 383 | 384 |
    385 | 386 | {({ fonts }) => 387 | fonts.map((font, i) => ( 388 |
  • 389 | {font.loaded ? "✔︎" : font.error ? "✘" : "…"}{" "} 390 | 398 | {font.family} 399 | {font.weight ? ` • ${font.weight}` : ""} 400 | {font.style ? ` • ${font.style}` : ""} 401 | {font.stretch ? ` • ${font.stretch}` : ""} 402 | 403 |
  • 404 | )) 405 | } 406 |
    407 |
408 |
409 |
410 | Documentation 411 | 412 | All options are documented in the{" "} 413 | 418 | README 419 | 420 | . 421 | 422 |
423 |
424 |
425 | 426 | ); 427 | } 428 | } 429 | -------------------------------------------------------------------------------- /scripts/extract-docs.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const fs = require("fs"); 3 | const reactDocs = require("react-docgen"); 4 | const displayNameHandler = require("react-docgen-displayname-handler").default; 5 | 6 | const files = [ 7 | "src/TightenText.js", 8 | "src/PreventWidows.js", 9 | "src/Justify.js", 10 | "src/FontObserver.js", 11 | "src/FontObserverProvider.js", 12 | "src/TypesettingProvider.js" 13 | ]; 14 | 15 | const resolver = reactDocs.resolver.findExportedComponentDefinition; 16 | const handlers = reactDocs.defaultHandlers.concat([displayNameHandler]); 17 | 18 | console.log( 19 | JSON.stringify( 20 | files.reduce((output, file) => { 21 | const src = fs.readFileSync(file, "utf8"); 22 | const documentation = reactDocs.parse(src, resolver, handlers); 23 | output[documentation.displayName] = documentation; 24 | return output; 25 | }, {}) 26 | ) 27 | ); 28 | -------------------------------------------------------------------------------- /scripts/markdown-docs.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const path = require("path"); 3 | const escape = require("escape-html"); 4 | 5 | let json = ""; 6 | process.stdin.setEncoding("utf8"); 7 | process.stdin.on("readable", () => { 8 | var chunk = process.stdin.read(); 9 | if (chunk !== null) { 10 | json += chunk; 11 | } 12 | }); 13 | 14 | process.stdin.on("end", () => { 15 | buildDocs(JSON.parse(json)); 16 | }); 17 | 18 | function buildDocs(components) { 19 | for (const filename in components) { 20 | const component = components[filename]; 21 | const componentName = path.basename(filename, ".js"); 22 | console.log(`\n### ${componentName}\n`); 23 | if (component.description) { 24 | console.log(`${component.description}\n`); 25 | } 26 | const propNames = Object.keys(component.props || {}); 27 | if (propNames.length) { 28 | console.log(`#### Props\n`); 29 | console.log(""); 30 | console.log(""); 31 | console.log(""); 32 | console.log(""); 33 | console.log(''); 34 | console.log(""); 35 | console.log(""); 36 | console.log(""); 37 | console.log(""); 38 | console.log(""); 39 | propNames.forEach(name => { 40 | const prop = component.props[name]; 41 | const required = s => 42 | prop.required ? `${s}` : s; 43 | const extraRows = []; 44 | let type = renderType(prop.type, extraRows, 0, true); 45 | if (type.includes("\n")) { 46 | type = `\n${type}\n`; 47 | } 48 | console.log(``); 49 | console.log( 50 | `` 53 | ); 54 | console.log(``); 55 | console.log( 56 | `` 58 | ); 59 | if (prop.description) { 60 | console.log( 61 | ``); 65 | } else { 66 | console.log( 67 | `` 69 | ); 70 | } 71 | console.log(""); 72 | extraRows.forEach(row => { 73 | console.log(`${row}`); 74 | }); 75 | }); 76 | console.log(""); 77 | console.log("
NameTypeDefaultDescription
${required( 51 | name 52 | )}${type}${renderValue(prop.defaultValue)}` 62 | ); 63 | console.log(`\n${prop.description}\n`); 64 | console.log(`
"); 78 | } 79 | } 80 | } 81 | 82 | const TYPES = { 83 | string: "String", 84 | number: "Number", 85 | func: "Function", 86 | bool: "Boolean", 87 | element: "React Element", 88 | object: "Object", 89 | node: "Node" 90 | }; 91 | 92 | function renderShape(value, extraRows, topLevel) { 93 | const ref = topLevel ? null : ++REF_COUNTER; 94 | const nextPos = topLevel 95 | ? extraRows.length 96 | : extraRows.push( 97 | `${ref} Object` 98 | ); 99 | extraRows.splice( 100 | nextPos, 101 | 0, 102 | ...Object.keys(value).map(key => { 103 | const name = value[key].required 104 | ? `${key}` 105 | : key; 106 | const type = renderType(value[key], extraRows); 107 | return `${name}${type}`; 108 | }) 109 | ); 110 | if (topLevel) { 111 | return "Object"; 112 | } 113 | const tooltip = `{ ${Object.keys(value).join(", ")} }`; 114 | return `Object ${ref}`; 115 | } 116 | 117 | let REF_COUNTER = 0; 118 | 119 | function renderType(value, extraRows = [], depth = 0, topLevel = false) { 120 | const indent = Array(2 * depth + 1).join(" "); 121 | if (value.name in TYPES) { 122 | return TYPES[value.name]; 123 | } 124 | if (value.name === "union") { 125 | return `One of…
\n${indent}  ${value.value 126 | .map(t => renderType(t, extraRows, depth + 1)) 127 | .join(`
\n${indent}  `)}`; 128 | } 129 | if (value.name === "arrayOf") { 130 | return `Array of…
\n${indent}  ${renderType( 131 | value.value, 132 | extraRows, 133 | depth + 1 134 | )}`; 135 | } 136 | if (value.name === "shape") { 137 | return renderShape(value.value, extraRows, topLevel); 138 | } 139 | throw new Error(`Unsupported type: ${value.name}`); 140 | } 141 | 142 | const CODE_CHARS = { 143 | "\0": '\\0', 144 | "\t": '\\t', 145 | "\n": '\\n', 146 | "\r": '\\r', 147 | "\u00A0": '\\u00A0' 148 | }; 149 | 150 | function renderValue(value, indent = 0) { 151 | if (!value) { 152 | return ""; 153 | } 154 | if (value.value === "undefined") { 155 | return ""; 156 | } 157 | if (value.value.match(/^true|false$/)) { 158 | return value.value; 159 | } 160 | if (value.value.match(/^[ \d./*+-]*$/)) { 161 | // eslint-disable-next-line no-eval 162 | return escape("" + eval(value.value)); 163 | } 164 | if (value.value.match(/^"([^"]|[\\"])+"$/)) { 165 | // eslint-disable-next-line no-eval 166 | return escape(eval(value.value)).replace(/./g, match => { 167 | return CODE_CHARS[match] || match; 168 | }); 169 | } 170 | return value.value; 171 | } 172 | -------------------------------------------------------------------------------- /src/FontObserver.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import Context from "./FontObserverContext"; 4 | import FontObserverProvider from "./FontObserverProvider"; 5 | 6 | /** 7 | * ```js 8 | * import { FontObserver } from 'react-typesetting'; 9 | * ``` 10 | * 11 | * A component for observing the fonts specified in the `FontObserver.Provider` 12 | * component. 13 | */ 14 | export default function FontObserver(props) { 15 | return ; 16 | } 17 | 18 | FontObserver.Provider = FontObserverProvider; 19 | 20 | FontObserver.propTypes = { 21 | /** 22 | * A function that will receive the current status of the observed fonts. 23 | * The argument will be an object with these properties: 24 | * 25 | * - `fonts`: An array of the fonts passed to `FontObserver.Provider`, each 26 | * with a `loaded` and `error` property. 27 | * - `loaded`: Whether all observed fonts are done loading. 28 | * - `error`: If any fonts failed to load, this will be populated with the 29 | * first error. 30 | */ 31 | children: PropTypes.func 32 | }; 33 | -------------------------------------------------------------------------------- /src/FontObserverContext.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default React.createContext({ 4 | fonts: [], 5 | loaded: false, 6 | error: null 7 | }); 8 | -------------------------------------------------------------------------------- /src/FontObserverProvider.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import FontFaceObserver from "fontfaceobserver"; 4 | import createLogger from "debug"; 5 | import Context from "./FontObserverContext"; 6 | 7 | const debug = createLogger("react-typesetting:FontObserver"); 8 | 9 | /** 10 | * ```js 11 | * import { FontObserver } from 'react-typesetting'; 12 | * ``` 13 | * 14 | * A context provider for specifying which fonts to observe. 15 | */ 16 | export default class FontObserverProvider extends React.Component { 17 | static displayName = "FontObserver.Provider"; 18 | 19 | static propTypes = { 20 | /** 21 | * The fonts to load and observe. The font files themselves should already 22 | * be specified somewhere (in CSS), this component simply uses `FontFaceObserver` 23 | * to force them to load (if necessary) and observe when they are ready. 24 | * 25 | * Each item in the array specifies the font `family`, `weight`, `style`, 26 | * and `stretch`, with only `family` being required. Additionally, each item 27 | * can contain a custom `testString` and `timeout` for that font, if they 28 | * should differ from the defaults. If only the family name is needed, the 29 | * array item can just be a string. 30 | */ 31 | fonts: PropTypes.arrayOf( 32 | PropTypes.oneOfType([ 33 | PropTypes.string, 34 | PropTypes.shape({ 35 | /** 36 | * The font family name. 37 | */ 38 | family: PropTypes.string.isRequired, 39 | /** 40 | * The named or numeric font weight. 41 | */ 42 | weight: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), 43 | /** 44 | * The font style value. 45 | */ 46 | style: PropTypes.string, 47 | /** 48 | * The font stretch value. 49 | */ 50 | stretch: PropTypes.string, 51 | /** 52 | * A custom test string to pass to `FontFaceObserver` for this font. 53 | */ 54 | testString: PropTypes.string, 55 | /** 56 | * A custom timeout to pass to `FontFaceObserver` for this font. 57 | */ 58 | timeout: PropTypes.number 59 | }) 60 | ]) 61 | ).isRequired, 62 | /** 63 | * A custom test string to pass to the `load` method of `FontFaceObserver`, 64 | * to be used for all fonts that do not specify their own `testString`. 65 | */ 66 | testString: PropTypes.string, 67 | /** 68 | * A custom timeout in milliseconds to pass to the `load` method of 69 | * `FontFaceObserver`, to be used for all fonts that do not specify their 70 | * own `timeout`. 71 | */ 72 | timeout: PropTypes.number, 73 | /** 74 | * The content that will have access to font loading status via context. 75 | */ 76 | children: PropTypes.node 77 | }; 78 | 79 | mounted = false; 80 | 81 | fontDescriptors = this.props.fonts.map(font => { 82 | if (typeof font === "string") { 83 | font = { family: font }; 84 | } 85 | return { 86 | font, 87 | observer: null, 88 | promise: null 89 | }; 90 | }); 91 | 92 | state = { 93 | fonts: this.fontDescriptors.map(descriptor => { 94 | return { 95 | ...descriptor.font, 96 | loaded: false, 97 | error: null 98 | }; 99 | }), 100 | loaded: false, 101 | error: null 102 | }; 103 | 104 | handleLoad(font, i) { 105 | if (this.mounted) { 106 | debug( 107 | "Loaded font “%s” (#%s of %s)", 108 | font.family, 109 | i + 1, 110 | this.fontDescriptors.length 111 | ); 112 | this.setState(state => { 113 | const fonts = state.fonts.slice(); 114 | fonts[i] = { 115 | ...fonts[i], 116 | loaded: true 117 | }; 118 | return { 119 | fonts, 120 | loaded: fonts.length === 1 || fonts.every(font => font.loaded) 121 | }; 122 | }); 123 | } 124 | } 125 | 126 | handleError(font, i, err) { 127 | if (this.mounted) { 128 | debug( 129 | "Error loading font “%s” (#%s of %s)", 130 | font.family, 131 | i + 1, 132 | this.fontDescriptors.length 133 | ); 134 | this.setState(state => { 135 | const fonts = state.fonts.slice(); 136 | const error = err || new Error("Font failed to load"); 137 | fonts[i] = { 138 | ...fonts[i], 139 | error 140 | }; 141 | return { 142 | fonts, 143 | error: state.error || error 144 | }; 145 | }); 146 | } 147 | } 148 | 149 | componentDidMount() { 150 | this.mounted = true; 151 | 152 | const { 153 | testString: defaultTestString, 154 | timeout: defaultTimeout 155 | } = this.props; 156 | 157 | this.fontDescriptors.forEach((descriptor, i) => { 158 | const { 159 | family, 160 | testString = defaultTestString, 161 | timeout = defaultTimeout, 162 | ...variation 163 | } = descriptor.font; 164 | 165 | debug( 166 | "Creating FontFaceObserver for “%s” (#%s of %s)", 167 | family, 168 | i + 1, 169 | this.fontDescriptors.length 170 | ); 171 | const observer = new FontFaceObserver(family, variation); 172 | const promise = observer.load(testString, timeout); 173 | 174 | promise.then( 175 | this.handleLoad.bind(this, descriptor.font, i), 176 | this.handleError.bind(this, descriptor.font, i) 177 | ); 178 | 179 | descriptor.observer = observer; 180 | descriptor.promise = promise; 181 | }); 182 | } 183 | 184 | componentWillUnmount() { 185 | this.mounted = false; 186 | this.fontDescriptors = undefined; 187 | } 188 | 189 | render() { 190 | const { children } = this.props; 191 | return {children}; 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/Justify.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import createLogger from "debug"; 4 | import ResizeObserver from "./ResizeObserver"; 5 | import Typesetting from "./Typesetting"; 6 | 7 | const debug = createLogger("react-typesetting:Justify"); 8 | 9 | const justifyStyle = { 10 | position: "relative", 11 | textAlign: "justify" 12 | }; 13 | 14 | /** 15 | * ```js 16 | * import { Justify } from 'react-typesetting'; 17 | * ``` 18 | * 19 | * While this may include more advanced justification features in the future, it 20 | * is currently very simple: it conditionally applies `text-align: justify` to 21 | * its container element (a `

` by default) depending on whether or not there 22 | * is enough room to avoid large, unseemly word gaps. The minimum width is 23 | * defined by `minWidth` and defaults to 16 ems. 24 | * 25 | * You might also accomplish this with media queries, but this component can 26 | * determine the exact width available to the container element instead of just 27 | * the entire page. 28 | */ 29 | class Justify extends React.Component { 30 | static propTypes = { 31 | /** 32 | * The class to apply to the outer wrapper element created by this 33 | * component. 34 | */ 35 | className: PropTypes.string, 36 | /** 37 | * Extra style properties to add to the outer wrapper element created by 38 | * this component. 39 | */ 40 | style: PropTypes.object, 41 | /** 42 | * The content to render. 43 | */ 44 | children: PropTypes.node, 45 | /** 46 | * The element type in which to render the supplied children. It must be 47 | * a block level element, like `p` or `div`, since `text-align` has no 48 | * effect on inline elements. It may also be a custom React component, as 49 | * long as it uses `forwardRef`. 50 | */ 51 | as: PropTypes.oneOfType([ 52 | PropTypes.string, 53 | PropTypes.func, 54 | PropTypes.object 55 | ]), 56 | /** 57 | * The minimum width at which to allow justified text. Numbers indicate an 58 | * absolute pixel width. Strings will be applied to an element's CSS in 59 | * order to perform the width calculation. 60 | */ 61 | minWidth: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), 62 | /** 63 | * Whether or not to initially set `text-align: justify` before the 64 | * available width has been determined. 65 | */ 66 | initialJustify: PropTypes.bool, 67 | /** 68 | * If specified, disables automatic reflow so that you can trigger it 69 | * manually by changing this value. The prop itself does nothing, but 70 | * changing it will cause React to update the component. 71 | */ 72 | reflowKey: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), 73 | /** 74 | * Debounces reflows so they happen at most this often in milliseconds (at 75 | * the end of the given duration). If not specified, reflow is computed 76 | * every time the component is rendered. 77 | */ 78 | reflowTimeout: PropTypes.number, 79 | /** 80 | * Whether to completely disable justification detection. The last 81 | * alignment that was applied will be preserved. 82 | */ 83 | disabled: PropTypes.bool, 84 | /** 85 | * A function to call when layout has been recomputed and justification 86 | * has been applied or unapplied. 87 | */ 88 | onReflow: PropTypes.func, 89 | /** 90 | * The name of a preset defined in an outer `Typesetting.Provider` 91 | * component. If it exists, default values for all other props will come 92 | * from the specified preset. 93 | */ 94 | preset: PropTypes.string 95 | }; 96 | 97 | static defaultProps = { 98 | as: "p", 99 | minWidth: "16em", 100 | initialJustify: true 101 | }; 102 | 103 | hostRef = React.createRef(); 104 | widthNode = null; 105 | state = { justify: this.props.initialJustify }; 106 | 107 | handleResize = () => { 108 | debug("Detected resize, forcing update"); 109 | this.forceUpdate(); 110 | }; 111 | 112 | componentDidMount() { 113 | this.scheduleReflow(); 114 | } 115 | 116 | componentDidUpdate(prevProps, prevState, snapshot) { 117 | if (this.state.justify === prevState.justify) { 118 | this.scheduleReflow(snapshot); 119 | } 120 | } 121 | 122 | scheduleReflow(snapshot) { 123 | const { reflowTimeout, disabled } = this.props; 124 | if (disabled) { 125 | return; 126 | } 127 | if (!this.reflowScheduled) { 128 | this.reflowScheduled = true; 129 | if (reflowTimeout) { 130 | this.timeout = window.setTimeout(this.reflow, reflowTimeout); 131 | } else { 132 | this.raf = window.requestAnimationFrame(this.reflow); 133 | } 134 | } 135 | } 136 | 137 | reflow = () => { 138 | this.reflowScheduled = false; 139 | 140 | const { minWidth, onReflow } = this.props; 141 | 142 | if (!this.widthNode) { 143 | this.widthNode = document.createElement("span"); 144 | } 145 | this.widthNode.style.cssText = ` 146 | display: inline-block; 147 | position: absolute; 148 | left: 0; 149 | width: ${minWidth}; 150 | height: 1px; 151 | background: red; 152 | pointer-events: none; 153 | `; 154 | const { width: hostWidth } = this.hostRef.current.getBoundingClientRect(); 155 | this.hostRef.current.appendChild(this.widthNode); 156 | const { width: targetWidth } = this.widthNode.getBoundingClientRect(); 157 | this.hostRef.current.removeChild(this.widthNode); 158 | const justify = hostWidth >= targetWidth; 159 | this.setState(state => { 160 | if (state.justify !== justify) { 161 | return { justify }; 162 | } 163 | return null; 164 | }, onReflow); 165 | }; 166 | 167 | render() { 168 | const { as: Component, className, style, reflowKey, children } = this.props; 169 | const { justify } = this.state; 170 | const alignStyle = justify ? justifyStyle : undefined; 171 | const outerStyle = style ? { ...alignStyle, ...style } : alignStyle; 172 | const content = ( 173 | 174 | {children} 175 | 176 | ); 177 | return reflowKey == null ? ( 178 | 179 | {content} 180 | 181 | ) : ( 182 | content 183 | ); 184 | } 185 | } 186 | 187 | export default Typesetting.withPreset(Justify); 188 | -------------------------------------------------------------------------------- /src/PreventWidows.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import PropTypes from "prop-types"; 4 | import createLogger from "debug"; 5 | import ResizeObserver from "./ResizeObserver"; 6 | import Typesetting from "./Typesetting"; 7 | import { 8 | iterTextNodesReverse, 9 | replaceTextInNode, 10 | getLines, 11 | getLineWidth 12 | } from "./domUtils"; 13 | 14 | const debug = createLogger("react-typesetting:PreventWidows"); 15 | 16 | const NBSP = "\u00a0"; 17 | 18 | const defaultStyle = { 19 | position: "relative", 20 | display: "inline-block" 21 | }; 22 | 23 | let _nbspIncubator; 24 | 25 | /** 26 | * ```js 27 | * import { PreventWidows } from 'react-typesetting'; 28 | * ``` 29 | * Prevents [widows](https://www.fonts.com/content/learning/fontology/level-2/text-typography/rags-widows-orphans) 30 | * by measuring the width of the last line of text rendered by the component’s 31 | * children. Spaces will be converted to non-breaking spaces until the given 32 | * minimum width or the maximum number of substitutions is reached. 33 | * 34 | * By default, element resizes that may necessitate recomputing line widths are 35 | * automatically detected. By specifying the `reflowKey` prop, you can instead 36 | * take manual control by changing the prop whenever you’d like the component to 37 | * update. 38 | */ 39 | class PreventWidows extends React.PureComponent { 40 | static propTypes = { 41 | /** 42 | * The class to apply to the outer wrapper `span` created by this component. 43 | */ 44 | className: PropTypes.string, 45 | /** 46 | * Extra style properties to add to the outer wrapper `span` created by this 47 | * component. 48 | */ 49 | style: PropTypes.object, 50 | /** 51 | * The content to render. 52 | */ 53 | children: PropTypes.node, 54 | /** 55 | * The maximum number of spaces to substitute. 56 | */ 57 | maxSubstitutions: PropTypes.number, 58 | /** 59 | * The minimum width of the last line, below which non-breaking spaces will 60 | * be inserted until the minimum is met. 61 | * 62 | * * **Numbers** indicate an absolute pixel width. 63 | * * **Strings** indicate a CSS `width` value that will be computed by 64 | * temporarily injecting an element into the container and determining its 65 | * width. 66 | * * **Functions** will be called with relevant data to determine a dynamic 67 | * number or string value to return. This can be used, for example, to 68 | * have different rules at different breakpoints – like a media query. 69 | */ 70 | minLineWidth: PropTypes.oneOfType([ 71 | PropTypes.number, 72 | PropTypes.string, 73 | PropTypes.func 74 | ]), 75 | /** 76 | * A character or element to use when substituting spaces. Defaults to a 77 | * standard non-breaking space character, which you should almost certainly 78 | * stick with unless you want to visualize where non-breaking spaces are 79 | * being inserted for debugging purposes, or adjust their width. 80 | * 81 | * * **String** values will be inserted directly into the existing Text node 82 | * containing the space. 83 | * * **React Element** values will be rendered into an in-memory “incubator” 84 | * node, then transplanted into the DOM, splitting up the Text node in 85 | * which the space was found. 86 | * * **Function** values must produce a string, Text node, Element node, or 87 | * React Element to insert. 88 | */ 89 | nbspChar: PropTypes.oneOfType([ 90 | PropTypes.string, 91 | PropTypes.element, 92 | PropTypes.func 93 | ]), 94 | /** 95 | * If specified, disables automatic reflow so that you can trigger it 96 | * manually by changing this value. The prop itself does nothing, but 97 | * changing it will cause React to update the component. 98 | */ 99 | reflowKey: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), 100 | /** 101 | * Debounces reflows so they happen at most this often in milliseconds (at 102 | * the end of the given duration). If not specified, reflow is computed 103 | * every time the component is rendered. 104 | */ 105 | reflowTimeout: PropTypes.number, 106 | /** 107 | * Whether to completely disable widow prevention. 108 | */ 109 | disabled: PropTypes.bool, 110 | /** 111 | * A function to call when layout has been recomputed and space substitution 112 | * is done. 113 | */ 114 | onReflow: PropTypes.func, 115 | /** 116 | * The name of a preset defined in an outer `Typesetting.Provider` 117 | * component. If it exists, default values for all other props will come 118 | * from the specified preset. 119 | */ 120 | preset: PropTypes.string 121 | }; 122 | 123 | static defaultProps = { 124 | maxSubstitutions: 3, 125 | minLineWidth: "15%", 126 | nbspChar: NBSP 127 | }; 128 | 129 | outerRef = React.createRef(); 130 | innerRef = React.createRef(); 131 | reflowScheduled = false; 132 | undoFunctions = []; 133 | widthNode = null; 134 | 135 | handleResize = () => { 136 | debug("Detected resize, forcing update"); 137 | this.forceUpdate(); 138 | }; 139 | 140 | scheduleReflow(snapshot) { 141 | const { reflowTimeout, disabled } = this.props; 142 | if (disabled) { 143 | return; 144 | } 145 | if (!this.reflowScheduled) { 146 | this.reflowScheduled = true; 147 | if (reflowTimeout) { 148 | this.timeout = window.setTimeout(this.reflow, reflowTimeout); 149 | } else { 150 | this.raf = window.requestAnimationFrame(this.reflow); 151 | } 152 | } 153 | } 154 | 155 | createNbsp(nbspChar) { 156 | if (typeof nbspChar === "function") { 157 | return nbspChar(); 158 | } else if (React.isValidElement(nbspChar)) { 159 | if (!_nbspIncubator) { 160 | _nbspIncubator = document.createElement("div"); 161 | } 162 | // WARNING: This depends on `ReactDOM.render` being synchronous! 163 | ReactDOM.render(nbspChar, _nbspIncubator); 164 | const node = _nbspIncubator.childNodes[0]; 165 | ReactDOM.unmountComponentAtNode(_nbspIncubator); 166 | return node; 167 | } 168 | return nbspChar; 169 | } 170 | 171 | getSnapshotBeforeUpdate() { 172 | const startTime = Date.now(); 173 | this.undoFunctions.forEach(undo => undo()); 174 | debug( 175 | "Undid %s substitutions in %sms", 176 | this.undoFunctions.length, 177 | Date.now() - startTime 178 | ); 179 | return null; 180 | } 181 | 182 | reflow = () => { 183 | this.reflowScheduled = false; 184 | 185 | let startTime; 186 | if (debug.enabled) { 187 | startTime = Date.now(); 188 | } 189 | const { onReflow, nbspChar, maxSubstitutions, minLineWidth } = this.props; 190 | 191 | let widthFn; 192 | if (typeof minLineWidth === "string") { 193 | widthFn = options => { 194 | if (!this.widthNode) { 195 | this.widthNode = document.createElement("span"); 196 | } 197 | const { widthNode } = this; 198 | widthNode.style.cssText = ` 199 | display: block; 200 | position: absolute; 201 | left: 0; 202 | pointer-events: none; 203 | z-index: -1; 204 | visibility: hidden; 205 | width: ${minLineWidth}; 206 | `; 207 | const parentNode = options.outerRef.current; 208 | parentNode.appendChild(widthNode); 209 | const { width } = widthNode.getBoundingClientRect(); 210 | parentNode.removeChild(widthNode); 211 | debug( 212 | "Computed minLineWidth of %o to be %s pixels", 213 | minLineWidth, 214 | width 215 | ); 216 | return width || 0; 217 | }; 218 | } else if (typeof minLineWidth === "function") { 219 | widthFn = minLineWidth; 220 | } else { 221 | widthFn = () => minLineWidth || 0; 222 | } 223 | 224 | const undoFunctions = []; 225 | let targetWidth; 226 | 227 | while (undoFunctions.length < maxSubstitutions) { 228 | const rects = this.innerRef.current.getClientRects(); 229 | const lines = getLines(rects); 230 | if (lines.length < 2) { 231 | break; 232 | } 233 | const lineWidths = lines.map(getLineWidth); 234 | const maxLineWidth = Math.max(...lineWidths); 235 | const prevLineWidth = lineWidths[lineWidths.length - 2]; 236 | const lastLineWidth = lineWidths[lineWidths.length - 1]; 237 | if (targetWidth == null) { 238 | targetWidth = widthFn({ 239 | outerRef: this.outerRef, 240 | innerRef: this.innerRef, 241 | lineWidths, 242 | maxLineWidth, 243 | prevLineWidth, 244 | lastLineWidth 245 | }); 246 | } 247 | if (lastLineWidth >= targetWidth) { 248 | break; 249 | } 250 | // If meeting the target line width would require decreasing the 251 | // penultimate line's width below the minimum, bail out. 252 | if (prevLineWidth - (targetWidth - lastLineWidth) < targetWidth) { 253 | break; 254 | } 255 | const iter = iterTextNodesReverse(this.innerRef.current); 256 | let textNode = iter(); 257 | while (textNode !== null) { 258 | const text = textNode.nodeValue; 259 | const lastSpace = text.lastIndexOf(" "); 260 | if (lastSpace !== -1) { 261 | const nbsp = this.createNbsp(nbspChar); 262 | const undo = replaceTextInNode(textNode, lastSpace, 1, nbsp); 263 | undoFunctions.push(undo); 264 | break; 265 | } 266 | textNode = iter(); 267 | } 268 | } 269 | debug("Performed %s substitutions", undoFunctions.length); 270 | this.undoFunctions = undoFunctions; 271 | 272 | if (debug.enabled) { 273 | const endTime = Date.now(); 274 | debug("Reflow completed in %sms", endTime - startTime); 275 | } 276 | if (onReflow) { 277 | onReflow(); 278 | } 279 | }; 280 | 281 | componentDidMount() { 282 | this.scheduleReflow(); 283 | } 284 | 285 | componentDidUpdate(prevProps, prevState, snapshot) { 286 | this.scheduleReflow(snapshot); 287 | } 288 | 289 | componentWillUnmount() { 290 | if (this.reflowScheduled) { 291 | window.clearTimeout(this.timeout); 292 | window.cancelAnimationFrame(this.raf); 293 | } 294 | this.widthNode = null; 295 | } 296 | 297 | render() { 298 | const { className, style, reflowKey, children } = this.props; 299 | const outerStyle = style ? { ...defaultStyle, ...style } : defaultStyle; 300 | const content = ( 301 | 302 | {children} 303 | 304 | ); 305 | return reflowKey == null ? ( 306 | 307 | {content} 308 | 309 | ) : ( 310 | content 311 | ); 312 | } 313 | } 314 | 315 | export default Typesetting.withPreset(PreventWidows); 316 | -------------------------------------------------------------------------------- /src/ResizeObserver.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import ResizeObserver from "resize-observer-polyfill"; 4 | 5 | const style = { 6 | display: "block", 7 | position: "absolute", 8 | pointerEvents: "none", 9 | visibility: "hidden", 10 | zIndex: -1 11 | }; 12 | 13 | export default class ResizeObserverComponent extends React.PureComponent { 14 | static displayName = "ResizeObserver"; 15 | 16 | static propTypes = { 17 | /** 18 | * The content to render. This should only really be necessary if you pass 19 | * an `observe` prop and want to ensure that the given ref is populated. 20 | */ 21 | children: PropTypes.node, 22 | /** 23 | * A React ref to observe. If not given, an absolutely positioned `` 24 | * will be rendered and observed. 25 | */ 26 | observe: PropTypes.object, 27 | /** 28 | * Whether to also observe `document.body`. This helps to catch content 29 | * changes that occur as a result of media queries but may not affect the 30 | * size of the observed container. 31 | */ 32 | observeBody: PropTypes.bool, 33 | /** 34 | * A function to call when the element size changes. 35 | */ 36 | onResize: PropTypes.func 37 | }; 38 | 39 | static defaultProps = { 40 | observeBody: false 41 | }; 42 | 43 | hostRef = React.createRef(); 44 | observer = null; 45 | observedNode = null; 46 | 47 | handleResize = entries => { 48 | this.props.onResize(entries); 49 | }; 50 | 51 | componentDidMount() { 52 | const { observe = this.hostRef, observeBody } = this.props; 53 | this.observedNode = observe.current; 54 | this.observer = new ResizeObserver(this.handleResize); 55 | this.observer.observe(this.observedNode); 56 | if (observeBody) { 57 | this.observer.observe(document.body); 58 | } 59 | } 60 | 61 | componentDidUpdate(prevProps) { 62 | const { observe = this.hostRef, observeBody } = this.props; 63 | if (this.observedNode !== observe.current) { 64 | this.observer.unobserve(this.observedNode); 65 | this.observedNode = observe.current; 66 | this.observer.observe(this.observedNode); 67 | } 68 | if (observeBody && !prevProps.observeBody) { 69 | this.observer.observe(document.body); 70 | } else if (!observeBody && prevProps.observeBody) { 71 | this.observer.unobserve(document.body); 72 | } 73 | } 74 | 75 | componentWillUnmount() { 76 | this.observer.disconnect(); 77 | this.observer = null; 78 | this.observedNode = null; 79 | } 80 | 81 | render() { 82 | const { observe, children } = this.props; 83 | return ( 84 | 85 | {children} 86 | {observe ? null : } 87 | 88 | ); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/TightenText.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import createLogger from "debug"; 4 | import ResizeObserver from "./ResizeObserver"; 5 | import binarySearch from "./binarySearch"; 6 | import Typesetting from "./Typesetting"; 7 | 8 | const debug = createLogger("react-typesetting:TightenText"); 9 | 10 | const defaultStyle = { 11 | display: "inline-block", 12 | position: "relative", 13 | width: "100%" 14 | }; 15 | 16 | const innerStyle = { 17 | // Since this is applied to an inline element, the parent's `line-height` 18 | // should still control the rendered line height. But without this, browsers 19 | // will annoyingly shift around the height of the box as the `font-size` shrinks. 20 | lineHeight: 1 21 | }; 22 | 23 | const defaultFormatter = value => `${value}em`; 24 | 25 | /** 26 | * ```js 27 | * import { TightenText } from 'react-typesetting'; 28 | * ``` 29 | * 30 | * Tightens `word-spacing`, `letter-spacing`, and `font-size` (in that order) 31 | * by the minimum amount necessary to ensure a minimal number of wrapped lines 32 | * and overflow. 33 | * 34 | * The algorithm starts by setting the minimum of all values (defined by the 35 | * `minWordSpacing`, `minLetterSpacing`, and `minFontSize` props) to determine 36 | * whether adjusting these will result in fewer wrapped lines or less overflow. 37 | * If so, then a binary search is performed (with at most `maxIterations`) to 38 | * find the best fit. 39 | * 40 | * By default, element resizes that may necessitate refitting the text are 41 | * automatically detected. By specifying the `reflowKey` prop, you can instead 42 | * take manual control by changing the prop whenever you’d like the component to 43 | * update. 44 | * 45 | * Note that unlike with typical justified text, the fit adjustments must apply 46 | * to all lines of the text, not just the lines that need to be tightened, 47 | * because there is no way to target individual wrapped lines. Thus, this 48 | * component is best used sparingly for typographically important short runs 49 | * of text, like titles or labels. 50 | */ 51 | class TightenText extends React.PureComponent { 52 | static propTypes = { 53 | /** 54 | * The class to apply to the outer wrapper `span` created by this component. 55 | */ 56 | className: PropTypes.string, 57 | /** 58 | * Extra style properties to add to the outer wrapper `span` created by this 59 | * component. 60 | */ 61 | style: PropTypes.object, 62 | /** 63 | * The content to render. 64 | */ 65 | children: PropTypes.node, 66 | /** 67 | * Minimum word spacing in ems. Set this to 0 if word spacing should not be 68 | * adjusted. 69 | */ 70 | minWordSpacing: PropTypes.number, 71 | /** 72 | * Minimum letter spacing in ems. Set this to 0 if word spacing should not 73 | * be adjusted. 74 | */ 75 | minLetterSpacing: PropTypes.number, 76 | /** 77 | * Minimum `font-size` in ems. Set this to 1 if font size should not be 78 | * adjusted. 79 | */ 80 | minFontSize: PropTypes.number, 81 | /** 82 | * When performing a binary search to find the optimal value of each CSS 83 | * property, this sets the maximum number of iterations to run before 84 | * settling on a value. 85 | */ 86 | maxIterations: PropTypes.number, 87 | /** 88 | * If specified, disables automatic reflow so that you can trigger it 89 | * manually by changing this value. The prop itself does nothing, but 90 | * changing it will cause React to update the component. 91 | */ 92 | reflowKey: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), 93 | /** 94 | * Debounces reflows so they happen at most this often in milliseconds (at 95 | * the end of the given duration). If not specified, reflow is computed 96 | * every time the component is rendered. 97 | */ 98 | reflowTimeout: PropTypes.number, 99 | /** 100 | * Whether to completely disable refitting the text. Any fit adjustments 101 | * that have already been applied in a previous render will be preserved. 102 | */ 103 | disabled: PropTypes.bool, 104 | /** 105 | * A function to call when layout has been recomputed and the text is done 106 | * refitting. 107 | */ 108 | onReflow: PropTypes.func, 109 | /** 110 | * The name of a preset defined in an outer `Typesetting.Provider` 111 | * component. If it exists, default values for all other props will come 112 | * from the specified preset. 113 | */ 114 | preset: PropTypes.string 115 | }; 116 | 117 | static defaultProps = { 118 | minWordSpacing: -0.02, 119 | minLetterSpacing: -0.02, 120 | minFontSize: 0.97, 121 | maxIterations: 5 122 | }; 123 | 124 | outerRef = React.createRef(); 125 | innerRef = React.createRef(); 126 | reflowScheduled = false; 127 | 128 | scheduleReflow(snapshot) { 129 | const { reflowTimeout, disabled } = this.props; 130 | if (disabled) { 131 | return; 132 | } 133 | if (!this.reflowScheduled) { 134 | this.reflowScheduled = true; 135 | if (reflowTimeout) { 136 | this.timeout = window.setTimeout(this.reflow, reflowTimeout); 137 | } else { 138 | this.raf = window.requestAnimationFrame(this.reflow); 139 | } 140 | } 141 | } 142 | 143 | countLines() { 144 | return this.innerRef.current.getClientRects().length; 145 | } 146 | 147 | measureOverflow() { 148 | const node = this.outerRef.current; 149 | return node.scrollWidth - node.clientWidth; 150 | } 151 | 152 | resetStyle() { 153 | this.innerRef.current.style.cssText = ` 154 | word-spacing: 0; 155 | letter-spacing: 0; 156 | font-size: 1em; 157 | `; 158 | } 159 | 160 | updateStyle(property, value, formatter = defaultFormatter) { 161 | const outputValue = value == null ? value : formatter(value); 162 | debug("Setting property '%s' to '%s'", property, outputValue); 163 | this.innerRef.current.style[property] = outputValue; 164 | } 165 | 166 | /** 167 | * Adjust the word spacing, letter spacing, and font size applied to 168 | * `innerNode` in order to minimize the number of wrapped lines and the amount 169 | * of overflow. Styles are updated directly on `innerNode` without using 170 | * `setState` - this should be okay because this component owns `innerNode` 171 | * and does not alter it in any way via React. If React decides to replace 172 | * `innerNode`, then it should call `componentDidUpdate` and this method will 173 | * reset and recalculate the styles anyway. 174 | */ 175 | reflow = () => { 176 | this.reflowScheduled = false; 177 | 178 | let startTime; 179 | if (debug.enabled) { 180 | startTime = Date.now(); 181 | } 182 | const { 183 | minWordSpacing, 184 | minLetterSpacing, 185 | minFontSize, 186 | maxIterations, 187 | onReflow 188 | } = this.props; 189 | 190 | // Reset styles and determine the number of lines. This is the widest the 191 | // text gets and thus should result in the maximum number of lines. 192 | this.resetStyle(); 193 | const maxLineCount = this.countLines(); 194 | const maxOverflow = this.measureOverflow(); 195 | // If there's only one line, our job is done! Otherwise, find out if we 196 | // can apply styles to get fewer lines. 197 | if (maxLineCount > 1 || maxOverflow > 0) { 198 | this.innerRef.current.style.cssText = ` 199 | word-spacing: ${minWordSpacing}em; 200 | letter-spacing: ${minLetterSpacing}em; 201 | font-size: ${minFontSize}em; 202 | `; 203 | const minLineCount = this.countLines(); 204 | const minOverflow = this.measureOverflow(); 205 | debug( 206 | "Determined target line count %s -> %s and overflow %s -> %s", 207 | maxLineCount, 208 | minLineCount, 209 | maxOverflow, 210 | minOverflow 211 | ); 212 | // If tightening the text reduced the line count, perform a binary 213 | // search to find the widest the text can be. Otherwise, reset styles 214 | // again as they had no benefit. 215 | if (minLineCount < maxLineCount || minOverflow < maxOverflow) { 216 | // The measurement function should return `TOO_HIGH` if we should keep 217 | // searching for a smaller value and `TOO_LOW` if we should keep 218 | // searching for a larger value. 219 | const measure = () => 220 | this.countLines() > minLineCount || 221 | this.measureOverflow() > minOverflow 222 | ? binarySearch.TOO_HIGH 223 | : binarySearch.TOO_LOW; 224 | 225 | // First, check if shrinking the font size is even necessary to meet 226 | // our goal. If so, search for the optimal size, otherwise continue 227 | // on to letter spacing. 228 | this.updateStyle("fontSize", 1); 229 | if (measure() === binarySearch.TOO_HIGH) { 230 | binarySearch({ 231 | lowerBound: minFontSize, 232 | upperBound: 1, 233 | maxIterations, 234 | measure, 235 | update: value => this.updateStyle("fontSize", value) 236 | }); 237 | } else { 238 | // Do the same thing with letter spacing. 239 | this.updateStyle("letterSpacing", 0); 240 | if (measure() === binarySearch.TOO_HIGH) { 241 | binarySearch({ 242 | lowerBound: minLetterSpacing, 243 | upperBound: 0, 244 | maxIterations, 245 | measure, 246 | update: value => this.updateStyle("letterSpacing", value) 247 | }); 248 | } else { 249 | // We already know word spacing can't be 0, otherwise we wouldn't 250 | // have found a smaller line count / overflow in the first place 251 | // and wouldn't be in this branch. So there's no reason to reset 252 | // it first like the other values. Search for the optimal size 253 | // starting at the minimum. 254 | binarySearch({ 255 | lowerBound: minWordSpacing, 256 | upperBound: 0, 257 | maxIterations, 258 | measure, 259 | update: value => this.updateStyle("wordSpacing", value) 260 | }); 261 | } 262 | } 263 | } else { 264 | this.resetStyle(); 265 | } 266 | } 267 | if (debug.enabled) { 268 | const endTime = Date.now(); 269 | debug("Reflow completed in %sms", endTime - startTime); 270 | } 271 | if (onReflow) { 272 | onReflow(); 273 | } 274 | }; 275 | 276 | handleResize = () => { 277 | debug("Detected resize, forcing update"); 278 | this.forceUpdate(); 279 | }; 280 | 281 | componentDidMount() { 282 | this.scheduleReflow(); 283 | } 284 | 285 | componentDidUpdate(prevProps, prevState, snapshot) { 286 | this.scheduleReflow(snapshot); 287 | } 288 | 289 | componentWillUnmount() { 290 | if (this.reflowScheduled) { 291 | window.clearTimeout(this.timeout); 292 | window.cancelAnimationFrame(this.raf); 293 | } 294 | } 295 | 296 | render() { 297 | const { className, style, reflowKey, children } = this.props; 298 | const outerStyle = style ? { ...defaultStyle, ...style } : defaultStyle; 299 | const content = ( 300 | 301 | 302 | {children} 303 | 304 | 305 | ); 306 | return reflowKey == null ? ( 307 | 308 | {content} 309 | 310 | ) : ( 311 | content 312 | ); 313 | } 314 | } 315 | 316 | export default Typesetting.withPreset(TightenText); 317 | -------------------------------------------------------------------------------- /src/TightenText.md: -------------------------------------------------------------------------------- 1 | ```jsx 2 |

3 | Islay single malt Scotch whisky 4 |
5 | ``` 6 | -------------------------------------------------------------------------------- /src/Typesetting.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import Context from "./TypesettingContext"; 4 | import TypesettingProvider from "./TypesettingProvider"; 5 | 6 | /** 7 | * ```js 8 | * import { Typesetting } from 'react-typesetting'; 9 | * ``` 10 | */ 11 | export default function Typesetting(props) { 12 | return ; 13 | } 14 | 15 | function withPreset(Component) { 16 | const name = Component.displayName || Component.name || "Component"; 17 | 18 | function WithPreset(props) { 19 | if (props.preset) { 20 | return ( 21 | 22 | {({ presets }) => { 23 | const preset = presets[props.preset]; 24 | return ; 25 | }} 26 | 27 | ); 28 | } 29 | return ; 30 | } 31 | 32 | WithPreset.displayName = `withPreset(${name})`; 33 | 34 | return WithPreset; 35 | } 36 | 37 | Typesetting.Provider = TypesettingProvider; 38 | 39 | Typesetting.withPreset = withPreset; 40 | 41 | Typesetting.propTypes = { 42 | children: PropTypes.func 43 | }; 44 | -------------------------------------------------------------------------------- /src/TypesettingContext.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default React.createContext({ 4 | presets: {} 5 | }); 6 | -------------------------------------------------------------------------------- /src/TypesettingProvider.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import Context from "./TypesettingContext"; 4 | 5 | /** 6 | * ```js 7 | * import { Typesetting } from 'react-typesetting'; 8 | * ``` 9 | * 10 | * A context provider for defining presets for all other `react-typesetting` 11 | * components to use. 12 | */ 13 | export default class TypesettingProvider extends React.Component { 14 | static displayName = "Typesetting.Provider"; 15 | 16 | static propTypes = { 17 | /** 18 | * An object mapping preset names to default props. For example, given the 19 | * value: 20 | * 21 | * ```js 22 | * { myPreset: { minFontSize: 1, maxIterations: 3 } } 23 | * ``` 24 | * 25 | * …the `TightenText` component could use this preset by specifying the 26 | * `preset` prop: 27 | * 28 | * ```jsx 29 | * 30 | * ``` 31 | */ 32 | presets: PropTypes.object, 33 | /** 34 | * The content that will have access to the defined presets via context. 35 | */ 36 | children: PropTypes.node 37 | }; 38 | 39 | static defaultProps = { 40 | presets: {} 41 | }; 42 | 43 | static getDerivedStateFromProps(props) { 44 | return { presets: props.presets }; 45 | } 46 | 47 | render() { 48 | const { children } = this.props; 49 | return {children}; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/binarySearch.js: -------------------------------------------------------------------------------- 1 | const TOO_HIGH = 1; 2 | const TOO_LOW = -1; 3 | const OPTIMAL = 0; 4 | 5 | /** 6 | * Find an optimal value between `lowerBound` and `upperBound` by repeatedly 7 | * calling `update` and `measure` to determine if it's too high or too low. 8 | * After reaching the iteration limit, the final value will be determined by 9 | * `preference`, which indicates whether it's preferable to overshoot, 10 | * undershoot, or just settle for the last value no matter what. When this 11 | * function returns, `update` will already have been called with the final 12 | * value, so there's no need to use the return value to perform the final 13 | * update. 14 | */ 15 | export default function binarySearch({ 16 | lowerBound, 17 | upperBound, 18 | measure, 19 | update, 20 | limitPrecision = 6, 21 | maxIterations = 5, 22 | preference = TOO_LOW 23 | }) { 24 | let lastValue = upperBound; 25 | for (let i = 0; i < maxIterations; i++) { 26 | // Calculate the midpoint of the current upper and lower bounds. 27 | let middle = (upperBound + lowerBound) / 2; 28 | // Browsers limit the precision that CSS values can have. WebKit and Gecko 29 | // use a precision of 6. We can use this to our advantage to bail out 30 | // early if the number of iterations leads us into too-precise territory. 31 | if (limitPrecision !== false) { 32 | middle = parseFloat(middle.toPrecision(limitPrecision)); 33 | } 34 | if (middle === upperBound || middle === lowerBound) { 35 | // There's no point in iterating further due to limited precision. 36 | break; 37 | } 38 | lastValue = middle; 39 | update(middle); 40 | const result = measure(); 41 | switch (result) { 42 | case TOO_LOW: 43 | // Increase the lower bound so we search for a higher value. 44 | lowerBound = middle; 45 | break; 46 | case TOO_HIGH: 47 | // Decrease the upper bound so we search for a lower value. 48 | upperBound = middle; 49 | break; 50 | default: 51 | // We've found the optimal value. 52 | return middle; 53 | } 54 | } 55 | // It's possible that we've ended on an iteration that doesn't satisfy our 56 | // goal (e.g. minimizing the line count). If a preference is specified, 57 | // return the last value that satisfies that preference instead of the value 58 | // we landed on. 59 | let finalValue; 60 | switch (preference) { 61 | case TOO_LOW: 62 | finalValue = lowerBound; 63 | break; 64 | case TOO_HIGH: 65 | finalValue = upperBound; 66 | break; 67 | default: 68 | finalValue = lastValue; 69 | } 70 | if (lastValue !== finalValue) { 71 | update(finalValue); 72 | } 73 | return finalValue; 74 | } 75 | 76 | binarySearch.TOO_HIGH = TOO_HIGH; 77 | binarySearch.TOO_LOW = TOO_LOW; 78 | binarySearch.OPTIMAL = OPTIMAL; 79 | -------------------------------------------------------------------------------- /src/domUtils.js: -------------------------------------------------------------------------------- 1 | export function iterTextNodesReverse(node) { 2 | if (node.nodeType === 1) { 3 | let i = 0; 4 | let subIter = null; 5 | const iter = () => { 6 | if (subIter) { 7 | const value = subIter(); 8 | if (value !== null) { 9 | return value; 10 | } else { 11 | subIter = null; 12 | } 13 | } 14 | i += 1; 15 | const index = node.childNodes.length - i; 16 | if (index < 0) { 17 | return null; 18 | } 19 | const childNode = node.childNodes[index]; 20 | subIter = iterTextNodesReverse(childNode); 21 | return iter(); 22 | }; 23 | return iter; 24 | } else if (node.nodeType === 3) { 25 | let done = false; 26 | return () => { 27 | let value = null; 28 | if (!done) { 29 | value = node; 30 | done = true; 31 | } 32 | return value; 33 | }; 34 | } 35 | return () => null; 36 | } 37 | 38 | // export function replaceNodeWithText(node, replacement) { 39 | // const { parentNode, previousSibling, nextSibling } = node; 40 | // const summary = { inserted: [], updated: [], removed: [] }; 41 | // parentNode.removeChild(node); 42 | // summary.removed.push(node); 43 | // if (previousSibling && previousSibling.nodeType === 3) { 44 | // previousSibling.nodeValue += 45 | // typeof replacement === "string" ? replacement : replacement.nodeValue; 46 | // summary.updated.push(previousSibling); 47 | // } 48 | // if (nextSibling && nextSibling.nodeType === 3) { 49 | // if (summary.updated.length) { 50 | // previousSibling.nodeValue += nextSibling.nodeValue; 51 | // parentNode.removeChild(nextSibling); 52 | // summary.removed.push(nextSibling); 53 | // } else { 54 | // nextSibling.nodeValue = 55 | // (typeof replacement === "string" 56 | // ? replacement 57 | // : replacement.nodeValue) + nextSibling.nodeValue; 58 | // summary.updated.push(nextSibling); 59 | // } 60 | // } 61 | // if (!summary.updated.length) { 62 | // const replacementNode = 63 | // typeof replacement === "string" 64 | // ? document.createTextNode(replacement) 65 | // : replacement; 66 | // parentNode.insertBefore(replacementNode, nextSibling); 67 | // summary.inserted.push(replacementNode); 68 | // } 69 | // return summary; 70 | // } 71 | 72 | /** 73 | * Replaces the span of text in `textNode` indicated by `startIndex` and 74 | * `length` with the given `replacement` string or node. The return value is an 75 | * undo function; calling it should leave the DOM in the same state it was in 76 | * before any changes were made. 77 | */ 78 | export function replaceTextInNode(textNode, startIndex, length, replacement) { 79 | if ( 80 | replacement && 81 | typeof replacement === "object" && 82 | replacement.nodeType === 3 83 | ) { 84 | replacement = replacement.nodeValue; 85 | } 86 | const text = textNode.nodeValue; 87 | const head = text.slice(0, startIndex); 88 | const tail = text.slice(startIndex + length); 89 | if (typeof replacement === "string") { 90 | const newValue = head + replacement + tail; 91 | textNode.nodeValue = newValue; 92 | return () => { 93 | textNode.nodeValue = text; 94 | }; 95 | } else if (replacement.nodeType === 1) { 96 | const headNode = document.createTextNode(head); 97 | textNode.nodeValue = tail; 98 | textNode.parentNode.insertBefore(replacement, textNode); 99 | textNode.parentNode.insertBefore(headNode, replacement); 100 | return () => { 101 | // `parentNode` might not be valid anymore! 102 | if (headNode.parentNode) { 103 | headNode.parentNode.removeChild(headNode); 104 | } 105 | if (replacement.parentNode) { 106 | replacement.parentNode.removeChild(replacement); 107 | } 108 | textNode.nodeValue = text; 109 | }; 110 | } else { 111 | throw new Error(`Unexpected replacement type: ${replacement}`); 112 | } 113 | } 114 | 115 | export function getLines(rects) { 116 | const lines = []; 117 | for (let i = 0; i < rects.length; i++) { 118 | const rect = rects[i]; 119 | const lastIndex = lines.length - 1; 120 | if (lastIndex === -1 || rect.top > lines[lastIndex][0].top) { 121 | lines.push([rect]); 122 | } else { 123 | lines[lastIndex].push(rect); 124 | } 125 | } 126 | return lines; 127 | } 128 | 129 | export function getLineWidth(line) { 130 | const leftBound = Math.min(...line.map(rect => rect.left)); 131 | const rightBound = Math.max(...line.map(rect => rect.right)); 132 | return rightBound - leftBound; 133 | } 134 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default as FontObserver } from "./FontObserver"; 2 | export { default as Justify } from "./Justify"; 3 | export { default as PreventWidows } from "./PreventWidows"; 4 | export { default as TightenText } from "./TightenText"; 5 | export { default as Typesetting } from "./Typesetting"; 6 | --------------------------------------------------------------------------------