├── .gitignore ├── screenshots ├── index-desktop.png ├── index-mobile.png ├── small-example.png ├── latecomers-desktop.png ├── latecomers-mobile.png └── small-example-thread.png ├── LICENSE ├── annotate.js ├── README.md ├── styles.css └── index.html /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store -------------------------------------------------------------------------------- /screenshots/index-desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/molly/annotate/HEAD/screenshots/index-desktop.png -------------------------------------------------------------------------------- /screenshots/index-mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/molly/annotate/HEAD/screenshots/index-mobile.png -------------------------------------------------------------------------------- /screenshots/small-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/molly/annotate/HEAD/screenshots/small-example.png -------------------------------------------------------------------------------- /screenshots/latecomers-desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/molly/annotate/HEAD/screenshots/latecomers-desktop.png -------------------------------------------------------------------------------- /screenshots/latecomers-mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/molly/annotate/HEAD/screenshots/latecomers-mobile.png -------------------------------------------------------------------------------- /screenshots/small-example-thread.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/molly/annotate/HEAD/screenshots/small-example-thread.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Molly White 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 | -------------------------------------------------------------------------------- /annotate.js: -------------------------------------------------------------------------------- 1 | // Add .selected to only currently selected item. 2 | const deselectAllExcept = (selector) => { 3 | const allSelected = document.querySelectorAll('.selected'); 4 | allSelected.forEach( currentSelected => { 5 | if ( 6 | currentSelected.id !== selector && 7 | currentSelected.getAttribute('aria-details') !== selector 8 | ) { 9 | currentSelected.classList.remove('selected'); 10 | } 11 | }) 12 | } 13 | 14 | /** 15 | * Build out functionality to connect highlights and comments for navigation. 16 | * @param {boolean} isHighlight - true: highlight, false: comment 17 | * @returns Click handler on each highlight and comment 18 | */ 19 | const makeClickHandler = (isHighlight) => { 20 | return (event) => { 21 | let targetElement, selector, corresponding; 22 | if (isHighlight) { 23 | selector = event.target.getAttribute('aria-details'); 24 | targetElement = event.target; 25 | } else { 26 | if (event.target.getAttribute('role') === 'comment') { 27 | selector = event.target.id; 28 | targetElement = event.target; 29 | } else { 30 | // Depending on where they click, they may have targeted a child element 31 | const annotation = event.target.closest('[role="comment"]'); 32 | targetElement = annotation; 33 | selector = annotation.id; 34 | } 35 | } 36 | 37 | if (isHighlight) { 38 | corresponding = document.querySelector(`#${selector}`); 39 | } else { 40 | corresponding = document.querySelector(`[aria-details="${selector}"]`); 41 | } 42 | 43 | // Highlight click target and corresponding element, and scroll to corresponding element 44 | // If target is already highlighted, dehilight (and don't scroll) 45 | const isSelected = targetElement.classList.toggle('selected'); 46 | corresponding.classList.toggle('selected'); 47 | if (isSelected) { 48 | const prefersReducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); 49 | const prefersReducedMotion = !prefersReducedMotionQuery || prefersReducedMotionQuery.matches; 50 | corresponding.scrollIntoView({ 51 | behavior: prefersReducedMotion ? 'auto' : 'smooth', 52 | block: 'nearest', 53 | }); 54 | } 55 | 56 | // Ensure this is the only highlighted pair 57 | deselectAllExcept(selector); 58 | 59 | // Avoid bubbling through to the deselectAll function 60 | event.stopPropagation(); 61 | }; 62 | } 63 | 64 | // Remove .selected from all elements. 65 | const deselectAll = () => { 66 | const selectedComments = document.querySelectorAll('.selected'); 67 | selectedComments.forEach(selectedComment => selectedComment.classList.remove('selected')) 68 | } 69 | 70 | /** 71 | * - Switch html element class to "js." 72 | * - Listen for clicks on all highlights. 73 | * - Listen for clicks on all comments. 74 | * - Create deselect event on any click. 75 | */ 76 | const onInitialLoad = () => { 77 | document.documentElement.className = document.documentElement.className.replace( 78 | `no-js`, 79 | `js`, 80 | ); 81 | 82 | const highlights = document.querySelectorAll('mark'); 83 | highlights.forEach(highlight => (highlight.addEventListener('click', makeClickHandler(true)))); 84 | 85 | const comments = document.querySelectorAll('.annotation'); 86 | comments.forEach(comment => comment.addEventListener('click', makeClickHandler(false))) 87 | 88 | document.addEventListener('click', deselectAll); 89 | } 90 | 91 | // Run it only when the doc is ready. 92 | const bootup = () => { 93 | if (document.readyState != 'loading') { 94 | onInitialLoad(); 95 | } else { 96 | document.addEventListener('DOMContentLoaded', onInitialLoad); 97 | } 98 | } 99 | 100 | bootup() 101 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Annotate 2 | 3 | Annotate and publish text on the web! This project was created for ["The (Edited) Latecomer's Guide to Crypto"](https://www.mollywhite.net/annotations/latecomers-guide-to-crypto), but can be used to annotate any text document. 4 | 5 | View a live demo at https://molly.github.io/annotate/. 6 | 7 | ![](./screenshots/latecomers-desktop.png) 8 | 9 | ## How to use 10 | Copy the `index.html`, `styles.css`, and `annotate.js` files to your project. You should only need to edit the `index.html` file, unless you want to change the styling or JavaScript behavior. This project does not *require* the JavaScript, so if you want to leave it out, just omit the `annotate.js` file and remove the `` tag from the HTML file. The `screenshots` folder has full-size screenshots of the index page in both desktop ([`index-desktop.png`](https://github.com/molly/annotate/blob/main/screenshots/index-desktop.png)) and mobile ([`index-mobile.png`](https://github.com/molly/annotate/blob/main/screenshots/index-mobile.png)) views, so you can see what the HTML produces. 11 | 12 | Each section of the document follows this basic structure: 13 | 14 | ```html 15 |
16 |
17 | Text that's being annotated. 18 |
19 |
20 | 24 |
25 |
26 | ``` 27 | 28 | and produces: 29 | 30 | ![](./screenshots/small-example.png) 31 | 32 | ## Details 33 | 34 | Each section of text is captured in a row with left- and right-hand sections. The `
` element represents this row. Each side then has a div with the `content` class and either the `quote` or `note` classes. `quote` is the text being annotated, `note` is for the annotations. 35 | 36 | Each portion of highlighted text in the original source (left-hand side) is marked with `` tags. These must have a unique `aria-details` attribute that will correspond to the `id` of the annotation, which will enable visual focus highlighting on click. It can also optionally have a `data-annotation-id` to number the annotation, to help distinguish annotations when there are multiple in a section. 37 | 38 | Corresponding to the `` tag will be a div with either the `annotation` or `annotation-group` class on the right-hand side (the former for single annotations, the latter for grouped annotations). These must have `role="comment"` and an `id` that exactly matches the unique `aria-details` value of the highlighted text to which it corresponds. As with the highlighted text, it can have a `data-annotation-id` to number the annotation. 39 | 40 | ### Grouped annotations 41 | Within an annotation group, there will be one or more divs with the `annotation` class. These can contain a div with the class `commenter` to identify the writer, if there are multiple annotators working on the document. These do *not* need `role="comment`, `data-annotation-id`, or `id` since they're nested within an `annotation-group` with those attributes. 42 | 43 | In the case of multiple annotations within an annotation group, they can appear directly stacked, or threaded (rendering with increasing levels of indentation, to indicate that they are replies to one another). To thread comments, include the `thread` class on the second comment (the first reply). Any subsequent replies should be marked with the `thread-x` class, where `x` is the level of indentation from 2–10: `thread-2`, `thread-3`, ..., `thread-10`. Omit the `thread` classes to render multiple annotations in a stack without indentation. 44 | 45 | ```html 46 |
47 |
48 | Text that's being annotated. 49 |
50 |
51 | 62 |
63 |
64 | ``` 65 | 66 | ![](./screenshots/small-example-thread.png) 67 | 68 | ## Other source formats 69 | 70 | The original Latecomer's Guide project was created using [Pug](https://pugjs.org/) and [Sass](https://sass-lang.com). If you'd rather work with those, that source code lives over with my [website source](https://github.com/molly/website-v2): 71 | * [Pug](https://github.com/molly/website-v2/blob/master/src/pug/pages/annotations/latecomers-guide-to-crypto.pug) file 72 | * [Sass](https://github.com/molly/website-v2/blob/master/src/sass/reviews.sass) file 73 | 74 | ## Mobile display 75 | 76 | This is how the annotations display on mobile: 77 | 78 | 79 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | -webkit-text-size-adjust: 100%; 3 | --sans-serif: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 4 | Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; 5 | --border-color: hsl(0, 0%, 87%); 6 | } 7 | 8 | body { 9 | font-family: Georgia, "Times New Roman", Times, serif; 10 | color: hsl(0, 0%, 0%); 11 | font-size: 1.15rem; 12 | line-height: 1.55; 13 | margin: 0; 14 | } 15 | 16 | h2, 17 | h3, 18 | h4, 19 | h5 { 20 | font-family: sans-serif; 21 | font-family: var(--sans-serif); 22 | margin: 0; 23 | } 24 | 25 | h3 { 26 | text-transform: uppercase; 27 | } 28 | 29 | img { 30 | max-width: 100%; 31 | } 32 | 33 | a:hover { 34 | background-color: transparent; 35 | text-decoration: none; 36 | } 37 | 38 | .sr-only { 39 | border: 0; 40 | clip: rect(1px, 1px, 1px, 1px); 41 | -webkit-clip-path: inset(50%); 42 | clip-path: inset(50%); 43 | height: 1px; 44 | overflow: hidden; 45 | padding: 0; 46 | position: absolute; 47 | width: 1px; 48 | white-space: nowrap; 49 | } 50 | 51 | .title-block { 52 | margin: 0.625rem; 53 | text-align: center; 54 | } 55 | 56 | .title-image { 57 | max-width: 20rem; 58 | margin: 0 auto; 59 | } 60 | 61 | .byline { 62 | font-family: sans-serif; 63 | font-family: var(--sans-serif); 64 | font-size: 80%; 65 | max-width: 32rem; 66 | margin: 0 auto; 67 | } 68 | 69 | .byline:not(:first-child) { 70 | margin-top: 1rem; 71 | } 72 | 73 | .intro, 74 | .outro { 75 | max-width: 47rem; 76 | padding: 0 1rem; 77 | } 78 | 79 | .intro { 80 | margin: 0 auto 2rem auto; 81 | } 82 | 83 | .outro { 84 | margin: 1.4rem auto 3rem auto; 85 | } 86 | 87 | .maincontent { 88 | border-top: 1px solid var(--border-color); 89 | border-bottom: 1px solid var(--border-color); 90 | } 91 | 92 | .content { 93 | max-width: 43rem; 94 | margin: 1.65rem auto; 95 | padding: 0 1.25rem; 96 | } 97 | 98 | .content p { 99 | margin-top: 0; 100 | } 101 | 102 | .content h3, 103 | .content h4 { 104 | font-family: sans-serif; 105 | font-family: var(--sans-serif); 106 | margin: 0 0 1.25rem 0; 107 | } 108 | 109 | .content ul, 110 | .content ol { 111 | margin: 0; 112 | padding-inline-start: 1rem; 113 | } 114 | 115 | mark { 116 | background-color: hsla(55, 100%, 77%, 0.5); 117 | } 118 | 119 | mark:hover { 120 | cursor: pointer; 121 | background-color: hsla(55, 100%, 77%, 1); 122 | } 123 | 124 | mark.selected { 125 | background-color: hsla(55, 100%, 77%, 1); 126 | } 127 | 128 | mark[data-annotation-id]::after { 129 | content: attr(data-annotation-id); 130 | content: attr(data-annotation-id) / ""; 131 | display: inline-block; 132 | position: relative; 133 | border-radius: 50%; 134 | border: 1px solid hsl(0, 0%, 0%); 135 | width: 0.625rem; 136 | height: 0.625rem; 137 | font-size: 0.625rem; 138 | line-height: 0.625rem; 139 | padding: 1px; 140 | text-align: center; 141 | margin-left: 3px; 142 | margin-right: 2px; 143 | bottom: 2px; 144 | } 145 | 146 | .note { 147 | padding: 1.25rem 1.25rem; 148 | max-width: 50rem; 149 | font-family: sans-serif; 150 | font-family: var(--sans-serif); 151 | font-size: 90%; 152 | background-color: hsl(0, 0%, 94%); 153 | } 154 | 155 | .content > .annotation:not(:first-child), 156 | .content .content:not(:first-child), 157 | .content .annotation-group:not(:first-child) { 158 | margin-top: 0.625rem; 159 | } 160 | 161 | .annotation { 162 | background-color: hsl(0, 0%, 100%); 163 | padding: 0.625rem; 164 | border: 1px solid var(--border-color); 165 | } 166 | 167 | .annotation:hover { 168 | cursor: pointer; 169 | } 170 | 171 | .annotation[role="comment"][data-annotation-id]::after, 172 | .annotation-group[role="comment"][data-annotation-id]::after { 173 | content: attr(data-annotation-id); 174 | content: attr(data-annotation-id) / ""; 175 | font-family: Georgia, "Times New Roman", Times, serif; 176 | background-color: hsl(0, 0%, 100%); 177 | display: inline-block; 178 | position: absolute; 179 | border-radius: 50%; 180 | border: 1px solid var(--border-color); 181 | width: 0.875rem; 182 | height: 0.875rem; 183 | font-size: 0.875rem; 184 | line-height: 1; 185 | padding: 3px; 186 | text-align: center; 187 | left: -8px; 188 | top: -8px; 189 | } 190 | 191 | .annotation[role="comment"], 192 | .annotation-group[role="comment"] { 193 | position: relative; 194 | } 195 | 196 | .annotation-group .annotation:not(:first-child) { 197 | border-top-width: 0; 198 | } 199 | 200 | .annotation.selected, 201 | .annotation-group.selected .annotation { 202 | border-color: hsl(0, 0%, 60%); 203 | background-color: hsl(56, 100%, 97%); 204 | } 205 | 206 | .commenter { 207 | font-family: sans-serif; 208 | font-family: var(--sans-serif); 209 | font-size: 80%; 210 | color: hsl(0, 0%, 60%); 211 | } 212 | 213 | .no-margin { 214 | margin: 0; 215 | } 216 | 217 | .small { 218 | font-size: 80%; 219 | } 220 | 221 | .fine-print { 222 | font-size: 70%; 223 | } 224 | 225 | .annotation-group .annotation.thread { 226 | margin-left: 0.625rem; 227 | } 228 | 229 | .annotation-group .annotation.thread-2 { 230 | margin-left: 1.25rem; 231 | } 232 | 233 | .annotation-group .annotation.thread-3 { 234 | margin-left: 1.5rem; 235 | } 236 | 237 | .annotation-group .annotation.thread-4 { 238 | margin-left: 1.75rem; 239 | } 240 | 241 | .annotation-group .annotation.thread-5 { 242 | margin-left: 2rem; 243 | } 244 | 245 | .annotation-group .annotation.thread-6 { 246 | margin-left: 2.25rem; 247 | } 248 | 249 | .annotation-group .annotation.thread-7 { 250 | margin-left: 2.5rem; 251 | } 252 | 253 | .annotation-group .annotation.thread-8 { 254 | margin-left: 2.75rem; 255 | } 256 | 257 | .annotation-group .annotation.thread-9 { 258 | margin-left: 3rem; 259 | } 260 | 261 | .annotation-group .annotation.thread-10 { 262 | margin-left: 3.25rem; 263 | } 264 | 265 | /* Bigger displays */ 266 | 267 | @media screen and (min-width: 31.25rem) { 268 | .content, 269 | .note { 270 | padding-right: 2.5rem; 271 | padding-left: 2.5rem; 272 | } 273 | } 274 | 275 | @media screen and (min-width: 46rem) { 276 | .maincontent { 277 | display: grid; 278 | grid-template-columns: 2fr 3fr; 279 | } 280 | 281 | /* Trickery to create the white/grey background */ 282 | 283 | .maincontent::before { 284 | content: ""; 285 | grid-row: 1; 286 | grid-column: 1; 287 | } 288 | 289 | .maincontent::after { 290 | content: ""; 291 | grid-row: 1; 292 | grid-column: 2; 293 | background-color: hsl(0, 0%, 94%); 294 | } 295 | 296 | /* END trickery */ 297 | 298 | .article { 299 | padding-top: 1.25rem; 300 | padding-bottom: 1.25rem; 301 | grid-row: 1; 302 | grid-column: 1/3; 303 | display: grid; 304 | grid-template-columns: inherit; 305 | } 306 | 307 | .group { 308 | grid-column: 1/3; 309 | display: grid; 310 | grid-template-columns: inherit; 311 | } 312 | 313 | .content { 314 | margin: 0; 315 | padding: 0 1.2rem; 316 | } 317 | 318 | .quote { 319 | justify-self: flex-end; 320 | } 321 | } 322 | 323 | @media screen and (min-width: 62.5rem) { 324 | .content, 325 | .note { 326 | padding-right: 2.5rem; 327 | padding-left: 2.5rem; 328 | } 329 | } 330 | 331 | /* No-JS tweaks */ 332 | .no-jsmark:hover { 333 | cursor: auto; 334 | background-color: hsla(55, 100%, 77%, 0.5); 335 | } 336 | 337 | .no-js.annotation:hover { 338 | cursor: auto; 339 | } 340 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 |
36 |

The Velveteen Rabbit

37 | 44 | 45 |
46 |
47 |

Introductory text goes here.

48 |

49 | Use the small class to add explanatory notes. 50 |

51 |
52 |
53 |
54 |
55 |
56 |

57 | There was once a velveteen rabbit, and in the beginning he was 58 | really splendid. 59 | He was fat and bunchy, as a 60 | rabbit should be; his coat was spotted brown and white, he had 61 | real thread whiskers, and his ears were lined with pink sateen. On 62 | Christmas morning, when he sat wedged in the top of the Boy’s 63 | stocking, with a sprig of holly between his paws, the effect was 64 | charming. 65 |

66 |
67 | 68 |
69 | 74 |
75 |
76 |
77 |
78 |

79 | There were other things in the stocking, nuts and oranges and a 80 | toy engine, and chocolate almonds and a clockwork mouse, but the 81 | Rabbit was quite the best of all. For at least two hours the Boy 82 | loved him, and then Aunts and Uncles came to dinner, and there was 83 | a great rustling of tissue paper and unwrapping of parcels, and in 84 | the excitement of looking at all the new presents the Velveteen 85 | Rabbit was forgotten. 86 |

87 |
88 |
89 |
90 |
91 |

92 | For a long time he lived in the toy cupboard or on the nursery 93 | floor, and no one thought very much about him. He was naturally 94 | shy, and being only made of velveteen, some of the more expensive 95 | toys quite snubbed him. The mechanical toys were very superior, 96 | and looked down upon every one else; they were full of modern 97 | ideas, and pretended they were real. The model boat, who had lived 98 | through two seasons and lost most of his paint, caught the tone 99 | from them and never missed an opportunity of referring to his 100 | rigging in technical terms. The Rabbit could not claim to be a 101 | model of anything, for 102 | he didn’t know that real rabbits existed; he thought they were all stuffed with sawdust like himself, and 105 | he understood that sawdust was quite out-of-date and should never 106 | be mentioned in modern circles. Even Timothy, the jointed wooden 107 | lion, who was made by the disabled soldiers, and should have had 108 | broader views, put on airs and 109 | pretended he was connected with Government. Between them all the poor little Rabbit was made to feel 112 | himself very insignificant and commonplace, and the only person 113 | who was kind to him at all was the Skin Horse. 114 |

115 |
116 | 117 |
118 | 137 | 154 |
155 |
156 |
157 |
158 |

159 | The Skin Horse had lived longer in the nursery than any of the 160 | others. He was so old that his brown coat was bald in patches and 161 | showed the seams underneath, and 162 | most of the hairs in his tail had been pulled out to string 164 | bead necklaces. He was wise, for he had seen a long succession of mechanical 166 | toys arrive to boast and swagger, and by-and-by break their 167 | mainsprings and pass away, and he knew that they were only toys, 168 | and would never turn into anything else. For 169 | nursery magic 172 | is very strange and wonderful, and only those playthings that are 173 | old and wise and experienced like the Skin Horse understand all 174 | about it. 175 |

176 |
177 | 178 |
179 | 190 | 201 |
202 |
203 |
204 |
205 | 239 | 240 | 241 | --------------------------------------------------------------------------------