bookish-press 0.5.24 → 0.5.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/components/chapter/Callout.svelte +0 -1
  2. package/dist/components/chapter/Embed.svelte +4 -3
  3. package/dist/components/chapter/Figure.svelte +0 -1
  4. package/dist/components/chapter/Footnote.svelte +1 -0
  5. package/dist/components/chapter/Paragraph.svelte +7 -2
  6. package/dist/components/chapter/Quote.svelte +6 -6
  7. package/dist/components/editor/BookishEditor.svelte +9 -16
  8. package/dist/components/editor/ConfirmButton.svelte +1 -1
  9. package/dist/components/editor/EmbedEditor.svelte +3 -3
  10. package/dist/components/editor/ImageChooser.svelte +6 -26
  11. package/dist/components/editor/ImageThumbnail.svelte +61 -0
  12. package/dist/components/editor/ImageThumbnail.svelte.d.ts +19 -0
  13. package/dist/components/editor/TextEditor.svelte +5 -2
  14. package/dist/components/page/DefinitionView.svelte +43 -38
  15. package/dist/components/page/Edition.svelte +17 -2
  16. package/dist/components/page/Header.svelte +73 -69
  17. package/dist/components/page/Media.svelte +5 -4
  18. package/dist/components/page/MediaPreview.svelte +8 -2
  19. package/dist/components/page/MediaPreview.svelte.d.ts +1 -0
  20. package/dist/components/page/Page.svelte +6 -3
  21. package/dist/components/page/Reference.svelte +1 -1
  22. package/dist/components/page/Search.svelte +1 -1
  23. package/dist/components/page/TableOfContents.svelte +1 -4
  24. package/dist/components/page/TableOfContentsRow.svelte +1 -1
  25. package/dist/components/page/ThemeEditor.svelte +1 -1
  26. package/dist/models/book/BookMedia.d.ts +3 -0
  27. package/dist/models/book/BookMedia.js +22 -5
  28. package/dist/models/book/Edition.d.ts +5 -1
  29. package/dist/models/book/Edition.js +11 -6
  30. package/dist/models/book/Words.js +0 -1
  31. package/dist/models/chapter/ChapterNode.d.ts +1 -0
  32. package/dist/models/chapter/ChapterNode.js +3 -0
  33. package/package.json +7 -5
@@ -25,7 +25,6 @@ export let node;
25
25
  padding-bottom: var(--bookish-paragraph-spacing);
26
26
  line-height: var(--bookish-paragraph-line-height-tight);
27
27
  clear: both;
28
- position: relative;
29
28
  }
30
29
 
31
30
  .bookish-callout :global(h2),
@@ -1,11 +1,11 @@
1
1
  <svelte:options immutable={true} />
2
2
 
3
3
  <script>import { storage } from '../../models/Firebase';
4
- import { getCaret, getBook, getBase } from '../page/Contexts';
4
+ import { getCaret, getBook, getBase, isEditionEditable, } from '../page/Contexts';
5
5
  import Figure from './Figure.svelte';
6
6
  import { getContext } from 'svelte';
7
7
  export let node;
8
- export let editable = false;
8
+ export let editable = true;
9
9
  export let placeholder = '';
10
10
  export let imageOnly = false;
11
11
  $: url = node.getURL();
@@ -13,6 +13,7 @@ $: description = node.getDescription();
13
13
  let caret = getCaret();
14
14
  let book = getBook();
15
15
  let base = getBase();
16
+ let editionEditable = isEditionEditable();
16
17
  // Bookish editor should have passed down a way to set the active editor.
17
18
  let claimCaret = getContext('claimeditor');
18
19
  let dragging = false;
@@ -94,7 +95,7 @@ function handleClick() {
94
95
  credit={imageOnly ? undefined : node.getCredit()}
95
96
  >
96
97
  {#if url.trim().length === 0}
97
- {#if editable}
98
+ {#if editable && editionEditable}
98
99
  <p
99
100
  class={`bookish-figure-unspecified ${
100
101
  dragging ? 'bookish-figure-dragging' : ''
@@ -70,7 +70,6 @@ function focusFigure() {
70
70
 
71
71
  .caption {
72
72
  text-align: center;
73
- position: relative;
74
73
  color: var(--bookish-muted-color);
75
74
  line-height: var(--bookish-paragraph-line-height-tight);
76
75
  font-size: var(--bookish-block-font-size);
@@ -67,6 +67,7 @@ afterUpdate(() => $chapter?.layoutMarginals());
67
67
  font-weight: 300;
68
68
  font-size: var(--bookish-small-font-size);
69
69
  /* line-height: 1.5em; */
70
+ text-align: left;
70
71
  }
71
72
 
72
73
  .bookish-footnote-symbol {
@@ -19,10 +19,15 @@ $: classes =
19
19
  ($chapter && $chapter.highlightedID === id
20
20
  ? ' bookish-content-highlight'
21
21
  : '');
22
+ $: firstParagraph = $root.getFirstParagraph();
23
+ $: dropcap =
24
+ placeholder === undefined &&
25
+ node === firstParagraph &&
26
+ /^[a-zA-Z]/.test(firstParagraph.getFirstTextNode().getText());
22
27
  </script>
23
28
 
24
29
  {#if level === 0}
25
- <p data-nodeid={node.nodeID} class:placeholder
30
+ <p data-nodeid={node.nodeID} class:placeholder class:dropcap
26
31
  ><Format node={node.getFormat()} placeholder={placeholder ?? '¶'} /></p
27
32
  >
28
33
  {:else}
@@ -54,7 +59,7 @@ $: classes =
54
59
  }
55
60
 
56
61
  /* This implements a drop cap in the first letter of the first text in the first format of the first paragraph of a chapter (but only chapters). */
57
- :global(.chapter-content) p:first-of-type:not(.placeholder)::first-letter {
62
+ p.dropcap::first-letter {
58
63
  padding: 0 0.25rem;
59
64
  margin: 0 0.25rem 0 0;
60
65
  font-size: 4rem;
@@ -37,12 +37,11 @@ $: credit = node.getCredit();
37
37
  font-family: var(--bookish-header-font-family);
38
38
  font-weight: 400;
39
39
  font-style: italic;
40
- padding: 0;
41
- margin-top: var(--bookish-paragraph-spacing);
42
- margin-bottom: var(--bookish-paragraph-spacing);
43
- margin-left: var(--bookish-indent);
44
- margin-right: var(--bookish-indent);
45
- position: relative;
40
+ margin: 0;
41
+ padding-top: 0;
42
+ padding-bottom: var(--bookish-paragraph-spacing);
43
+ padding-left: var(--bookish-indent);
44
+ padding-right: var(--bookish-indent);
46
45
  }
47
46
 
48
47
  .quote > :global(p) {
@@ -52,6 +51,7 @@ $: credit = node.getCredit();
52
51
 
53
52
  .caption {
54
53
  margin-top: 0.5em;
54
+ margin-right: var(--bookish-indent);
55
55
  text-align: right;
56
56
  font-family: var(--bookish-header-font-family);
57
57
  font-style: italic;
@@ -16,9 +16,8 @@ import ErrorNode from '../../models/chapter/ErrorNode';
16
16
  import { caretRangeToIndexRange, indexRangeToCaretRange, } from '../../models/chapter/Caret';
17
17
  import commands from './Commands';
18
18
  import { afterUpdate, onMount, setContext } from 'svelte';
19
- import { getCaret, getRoot } from '../page/Contexts';
19
+ import { getCaret } from '../page/Contexts';
20
20
  import CaretView from './CaretView.svelte';
21
- import { writable } from 'svelte/store';
22
21
  const IDLE_TIME = 500;
23
22
  export let text;
24
23
  export let parser;
@@ -159,25 +158,18 @@ afterUpdate(() => {
159
158
  const lineHeight = lineHeightString.endsWith('px')
160
159
  ? parseInt(lineHeightString.substring(0, lineHeightString.length - 2))
161
160
  : undefined;
162
- // Is the caret in relatively positioned ancestors above the editor? Undo their offsets.
163
- let caretAncestor = startNode.parentElement;
161
+ // Figure out the relative position of this editor.
162
+ let editorAncestor = element;
164
163
  let relativeX = 0;
165
164
  let relativeY = 0;
166
- let fixedX = undefined;
167
- let fixedY = undefined;
168
- while (caretAncestor != null) {
169
- const style = window.getComputedStyle(caretAncestor);
165
+ while (editorAncestor != null) {
166
+ const style = window.getComputedStyle(editorAncestor);
170
167
  if (style.position === 'relative') {
171
- relativeX += caretAncestor.offsetLeft;
172
- relativeY += caretAncestor.offsetTop;
168
+ relativeX += editorAncestor.offsetLeft;
169
+ relativeY += editorAncestor.offsetTop;
173
170
  }
174
- else if (style.position === 'fixed') {
175
- fixedX = caretAncestor.offsetLeft;
176
- fixedY = caretAncestor.offsetTop;
177
- }
178
- caretAncestor = caretAncestor.parentElement;
171
+ editorAncestor = editorAncestor.parentElement;
179
172
  }
180
- // Is the caret in a fixed position marginal? Get it's position.
181
173
  // If we're after a new line, calculate the correct position, since selections don't actually render to the next line.
182
174
  const left = afterNewLine
183
175
  ? textRect.left
@@ -185,6 +177,7 @@ afterUpdate(() => {
185
177
  const top = afterNewLine && lineHeight
186
178
  ? rangeRect.top + lineHeight
187
179
  : rangeRect.top;
180
+ // Compute the final position of the caret.
188
181
  const position = {
189
182
  x: left + window.scrollX - relativeX,
190
183
  y: top + window.scrollY - relativeY,
@@ -9,7 +9,7 @@ let confirming = false;
9
9
  {#if !confirming}
10
10
  <Button {tooltip} command={() => (confirming = true)}><slot /></Button>
11
11
  {:else}
12
- <Button tooltip="no" command={() => (confirming = false)}>no</Button
12
+ <Button tooltip="no" command={() => (confirming = false)}>cancel</Button
13
13
  >&nbsp;<Button tooltip="yes" {command}>{confirm}</Button>
14
14
  {/if}
15
15
  </div>
@@ -43,10 +43,10 @@ function handleImageChange(event) {
43
43
  });
44
44
  }
45
45
  function handleImageSelection(image) {
46
- // Toggle the embed
47
46
  $caret?.edit(embed, embed.getURL() === image.url
48
- ? embed.withURL('').withDescription('')
49
- : embed.withURL(image.url));
47
+ ? // Toggle the embed if choosing the same image
48
+ embed.withURL('').withDescription('')
49
+ : embed.withURLs(image.url, image.thumb));
50
50
  }
51
51
  </script>
52
52
 
@@ -1,8 +1,8 @@
1
1
  <script>import { onMount } from 'svelte';
2
- import { getBook, getCaret } from '../page/Contexts';
2
+ import { getBook } from '../page/Contexts';
3
+ import ImageThumbnail from './ImageThumbnail.svelte';
3
4
  export let select;
4
5
  export let selection;
5
- let editor = getCaret();
6
6
  let book = getBook();
7
7
  let images = undefined;
8
8
  let expanded = false;
@@ -37,18 +37,10 @@ onMount(() => {
37
37
  <!-- Sort the images by their URL. There's probably a more meaningful sort,
38
38
  such as placing unused images at the front of the list. -->
39
39
  {#each images.sort((a, b) => a.url.localeCompare(b.url)) as image}
40
- <img
41
- class={`bookish-image-chooser-image ${
42
- image.url === selection ? 'selected' : ''
43
- }`}
44
- src={image.url}
45
- alt={`Image named ${image.url}`}
46
- tabIndex="0"
47
- on:click|stopPropagation={() => select(image)}
48
- on:keydown={(event) =>
49
- event.key === 'Enter' || event.key === ' '
50
- ? select(image)
51
- : undefined}
40
+ <ImageThumbnail
41
+ {image}
42
+ selected={image.url === selection}
43
+ {select}
52
44
  />
53
45
  {:else}
54
46
  No images uploaded
@@ -74,16 +66,4 @@ onMount(() => {
74
66
  height: auto;
75
67
  overflow-y: auto;
76
68
  }
77
-
78
- .bookish-image-chooser-image {
79
- display: inline-block;
80
- height: 2em;
81
- cursor: pointer;
82
- }
83
-
84
- .bookish-image-chooser-image.selected {
85
- outline: var(--app-chrome-border-size) solid
86
- var(--app-interactive-color);
87
- outline-offset: calc(-1 * var(--app-chrome-border-size));
88
- }
89
69
  </style>
@@ -0,0 +1,61 @@
1
+ <script>export let image;
2
+ export let selected;
3
+ export let select;
4
+ let retries = 3;
5
+ let error = false;
6
+ let loading = true;
7
+ function loadImage(url) {
8
+ retries--;
9
+ let img = new Image();
10
+ img.onerror = () => {
11
+ if (retries > 0) {
12
+ setTimeout(() => loadImage(url), 1000);
13
+ }
14
+ else {
15
+ loading = false;
16
+ error = true;
17
+ }
18
+ };
19
+ img.onload = () => {
20
+ loading = false;
21
+ };
22
+ img.src = url;
23
+ }
24
+ loadImage(image.thumb);
25
+ </script>
26
+
27
+ {#if loading}
28
+ <div class="thumbnail loading" />
29
+ {:else}
30
+ <img
31
+ class="thumbnail"
32
+ class:selected
33
+ src={error ? image.url : image.thumb}
34
+ alt={`Image named ${image.url}`}
35
+ tabIndex="0"
36
+ on:click|stopPropagation={() => select(image)}
37
+ on:keydown={(event) =>
38
+ event.key === 'Enter' || event.key === ' '
39
+ ? select(image)
40
+ : undefined}
41
+ />
42
+ {/if}
43
+
44
+ <style>
45
+ .thumbnail {
46
+ display: inline-block;
47
+ height: 2em;
48
+ cursor: pointer;
49
+ }
50
+
51
+ .thumbnail.selected {
52
+ outline: var(--app-chrome-border-size) solid
53
+ var(--app-interactive-color);
54
+ outline-offset: calc(-1 * var(--app-chrome-border-size));
55
+ }
56
+
57
+ .loading {
58
+ width: 2.5em;
59
+ background: var(--app-chrome-background);
60
+ }
61
+ </style>
@@ -0,0 +1,19 @@
1
+ import { SvelteComponentTyped } from "svelte";
2
+ import type { Image } from '../../models/book/BookMedia';
3
+ declare const __propDef: {
4
+ props: {
5
+ image: Image;
6
+ selected: boolean;
7
+ select: (image: Image) => void;
8
+ };
9
+ events: {
10
+ [evt: string]: CustomEvent<any>;
11
+ };
12
+ slots: {};
13
+ };
14
+ export type ImageThumbnailProps = typeof __propDef.props;
15
+ export type ImageThumbnailEvents = typeof __propDef.events;
16
+ export type ImageThumbnailSlots = typeof __propDef.slots;
17
+ export default class ImageThumbnail extends SvelteComponentTyped<ImageThumbnailProps, ImageThumbnailEvents, ImageThumbnailSlots> {
18
+ }
19
+ export {};
@@ -24,7 +24,7 @@ $: {
24
24
  // them with non-breaking spaces.
25
25
  const sizedText = showPlaceholder()
26
26
  ? placeholder
27
- : text.replace(/\s/g, '\u00a0');
27
+ : text.replace(/\s$/g, '\u00a0');
28
28
  // Clip the text to prevent this editor from getting too long when editing. If it goes past one line,
29
29
  // the measurements and layout are way off.
30
30
  const trimmedText = width !== undefined &&
@@ -111,7 +111,6 @@ function handleKeyPress(event) {
111
111
  bind:this={field}
112
112
  bind:value={text}
113
113
  required
114
- role="textbox"
115
114
  aria-invalid={error !== undefined}
116
115
  aria-label={label}
117
116
  aria-placeholder={label}
@@ -136,6 +135,8 @@ function handleKeyPress(event) {
136
135
  <style>
137
136
  .text-editor {
138
137
  position: relative;
138
+ display: inline-block;
139
+ max-width: 100%;
139
140
  }
140
141
 
141
142
  input[type='text'] {
@@ -175,6 +176,8 @@ function handleKeyPress(event) {
175
176
 
176
177
  /* This ensures the error message always appears below empty text editors */
177
178
  .sizer {
179
+ display: inline-block;
180
+ max-width: 100%;
178
181
  min-height: 1em;
179
182
  opacity: 0;
180
183
  border: none;
@@ -5,13 +5,13 @@ import BookishEditor from '../editor/BookishEditor.svelte';
5
5
  import Format from '../chapter/Format.svelte';
6
6
  import Note from '../editor/Note.svelte';
7
7
  import { afterUpdate } from 'svelte';
8
- import { getUser, getEdition, getLeasee, isChapterEditable, lease, } from './Contexts';
8
+ import { getUser, getEdition, getLeasee, isChapterEditable, lease, isEditionEditable, } from './Contexts';
9
9
  import Button from '../app/Button.svelte';
10
10
  export let id;
11
11
  export let definition;
12
12
  let auth = getUser();
13
13
  let edition = getEdition();
14
- let editable = isChapterEditable();
14
+ let editable = isEditionEditable();
15
15
  // Focus after adding a new synonym.
16
16
  let newSynonym = false;
17
17
  let synonymsEditor = null;
@@ -36,6 +36,23 @@ function addSynonym() {
36
36
  }
37
37
  newSynonym = true;
38
38
  }
39
+ function removeSynonym(index) {
40
+ if (definition.synonyms &&
41
+ index >= 0 &&
42
+ index < definition.synonyms.length)
43
+ updateSynonyms([
44
+ ...definition.synonyms.slice(0, index),
45
+ ...definition.synonyms.slice(index + 1),
46
+ ]);
47
+ }
48
+ function updateSynonyms(synonyms) {
49
+ if ($edition)
50
+ edition.set($edition.withEditedDefinition(id, {
51
+ phrase: definition.phrase,
52
+ definition: definition.definition,
53
+ synonyms: synonyms,
54
+ }));
55
+ }
39
56
  $: syns = definition.synonyms || [];
40
57
  </script>
41
58
 
@@ -67,11 +84,11 @@ $: syns = definition.synonyms || [];
67
84
  {#if editable}
68
85
  <ConfirmButton
69
86
  tooltip="delete definition of {definition.phrase}"
70
- confirm="remove definition?"
87
+ confirm="delete entry"
71
88
  command={() =>
72
89
  $edition
73
90
  ? edition.set($edition.withoutDefinition(id))
74
- : undefined}>- definition</ConfirmButton
91
+ : undefined}>⨉</ConfirmButton
75
92
  >
76
93
  {/if}
77
94
  </dt>
@@ -111,40 +128,26 @@ $: syns = definition.synonyms || [];
111
128
  {/if}
112
129
  {#if edition && editable}
113
130
  <span bind:this={synonymsEditor}>
114
- {#if syns.length === 0}
115
- <Note>no synonyms</Note>
116
- {:else}
117
- {#each syns as syn, index}
118
- <Note>
119
- <TextEditor
120
- text={syn}
121
- label={'Synonym editor.'}
122
- placeholder="synonym"
123
- valid={() => undefined}
124
- save={(text) => {
125
- const newSyns = [...syns];
126
- // Remove the synonym if it's empty but wasn't before
127
- if (syn.length > 0 && text.length === 0)
128
- newSyns.splice(index, 1);
129
- else newSyns[index] = text;
130
- if ($edition)
131
- edition.set(
132
- $edition.withEditedDefinition(
133
- id,
134
- {
135
- phrase: definition.phrase,
136
- definition:
137
- definition.definition,
138
- synonyms: newSyns,
139
- }
140
- )
141
- );
142
- }}
143
- />
144
- </Note>
145
- {#if syns.length > 1 && index < syns.length - 1},&nbsp;{/if}
146
- {/each}
147
- {/if}
131
+ {#each syns as syn, index}
132
+ <Note>
133
+ <TextEditor
134
+ text={syn}
135
+ label={'Synonym editor.'}
136
+ placeholder="synonym"
137
+ valid={() => undefined}
138
+ save={(text) => {
139
+ const newSyns = [...syns];
140
+ newSyns[index] = text;
141
+ updateSynonyms(newSyns);
142
+ }}
143
+ />
144
+ <Button
145
+ tooltip="delete synonym"
146
+ command={() => removeSynonym(index)}>x</Button
147
+ >
148
+ </Note>
149
+ {#if syns.length > 1 && index < syns.length - 1},&nbsp;{/if}
150
+ {/each}
148
151
  </span>&nbsp;<Button tooltip="add synonym" command={addSynonym}
149
152
  >+ synonym</Button
150
153
  >
@@ -162,6 +165,8 @@ $: syns = definition.synonyms || [];
162
165
  }
163
166
 
164
167
  dt {
168
+ font-family: var(--bookish-header-font-family);
165
169
  font-weight: bold;
170
+ vertical-align: baseline;
166
171
  }
167
172
  </style>
@@ -2,9 +2,10 @@
2
2
  import { goto } from '$app/navigation';
3
3
  import { onMount, setContext, tick } from 'svelte';
4
4
  import { writable } from 'svelte/store';
5
- import { BASE, DARK_MODE, } from './Contexts';
5
+ import { BASE, DARK_MODE, getBook, } from './Contexts';
6
6
  import { BookishTheme } from '../../models/book/Theme';
7
7
  import { isDark, setDark } from '../../util/dark';
8
+ import { page } from '$app/stores';
8
9
  // Poly fill smooth scrolling for Safari.
9
10
  onMount(() => smoothscroll.polyfill());
10
11
  // The book edition to render.
@@ -14,6 +15,7 @@ export let edition;
14
15
  // but when the book is being viewed or edited in the Bookish app, it needs a prefix for the
15
16
  // route in the app.
16
17
  export let base = '';
18
+ let book = getBook();
17
19
  // When the base changes, update the context.
18
20
  let baseStore = writable(base);
19
21
  $: setContext(BASE, baseStore);
@@ -80,9 +82,22 @@ function toRules(set) {
80
82
  <!-- Keep the imports up to date -->
81
83
  <svelte:head>
82
84
  {@html '<' + `style>${themeCSS}</style>`}
85
+
86
+ <meta property="og:title" content={edition.getTitle()} />
87
+ <meta property="og:image" content={edition.getImage('cover')} />
88
+ <meta property="og:description" content={edition.getDescription()} />
89
+ <meta property="og:type" content="book" />
90
+ <meta property="og:author" content={edition.getAuthorsText()} />
91
+ <meta
92
+ property="og:url"
93
+ content={edition.base ??
94
+ `https://bookish.press/${
95
+ $book && $book.domain ? $book.domain : $page.params.bookid
96
+ }`}
97
+ />
83
98
  </svelte:head>
84
99
 
85
- <main class="bookish {$darkMode ? ' dark' : ''}" style={themeCSS}>
100
+ <main class="bookish {$darkMode ? ' dark' : ''}">
86
101
  <slot />
87
102
  </main>
88
103
 
@@ -54,80 +54,84 @@ let embed;
54
54
  $: embed = getImage();
55
55
  </script>
56
56
 
57
- <header class="bookish-chapter-header">
58
- {#if embed}
59
- <div class="bookish-figure-full">
60
- {#if $edition && editable}
61
- <BookishEditor
62
- text={embed}
63
- parser={(text) => Parser.parseEmbed($edition, text)}
64
- save={(node) =>
65
- setImage ? setImage(node.toBookdown()) : undefined}
66
- chapter={false}
67
- component={Embed}
68
- placeholder=""
69
- leasee={getLeasee(auth, edition, `embed-${id}`)}
70
- lease={(lock) => lease(auth, edition, `embed-${id}`, lock)}
71
- />
72
- {:else}
73
- <Embed
74
- node={Parser.parseEmbed($edition, embed)}
75
- editable={false}
76
- />
77
- {/if}
78
- {#if !print && showReminder}
79
- <div class="bookish-scroll-reminder" />
80
- {/if}
81
- </div>
82
- {:else}
83
- <!-- Add a bit of space to account for the lack of an image. -->
84
- <p>&nbsp;</p>
85
- {/if}
86
- {#if !print}
87
- <slot name="outline" />
88
- {/if}
89
- {#if editable}
90
- {#if embed === null}
91
- <Button tooltip="add cover image" command={addCover}
92
- >+ cover image</Button
93
- >
57
+ <!-- We key on the chapter ID to avoid laggy updates from image loading -->
58
+ {#key id}
59
+ <header class="bookish-chapter-header">
60
+ {#if embed}
61
+ <div class="bookish-figure-full">
62
+ {#if $edition && editable}
63
+ <BookishEditor
64
+ text={embed}
65
+ parser={(text) => Parser.parseEmbed($edition, text)}
66
+ save={(node) =>
67
+ setImage ? setImage(node.toBookdown()) : undefined}
68
+ chapter={false}
69
+ component={Embed}
70
+ placeholder=""
71
+ leasee={getLeasee(auth, edition, `embed-${id}`)}
72
+ lease={(lock) =>
73
+ lease(auth, edition, `embed-${id}`, lock)}
74
+ />
75
+ {:else}
76
+ <Embed
77
+ node={Parser.parseEmbed($edition, embed)}
78
+ editable={false}
79
+ />
80
+ {/if}
81
+ {#if !print && showReminder}
82
+ <div class="bookish-scroll-reminder" />
83
+ {/if}
84
+ </div>
94
85
  {:else}
95
- <ConfirmButton
96
- confirm="remove image?"
97
- tooltip="remove cover image"
98
- command={removeCover}>- cover image</ConfirmButton
99
- >
86
+ <!-- Add a bit of space to account for the lack of an image. -->
87
+ <p>&nbsp;</p>
100
88
  {/if}
101
- {/if}
102
- <div bind:this={title} class="bookish-chapter-header-text">
103
- <slot name="before" />
104
- <Title>
105
- {#if editable && save}
106
- <TextEditor
107
- {label}
108
- text={header + (subtitle ? ': ' + subtitle : '')}
109
- placeholder="title"
110
- valid={(text) =>
111
- text.length === 0
112
- ? 'Titles have to be at least one character long.'
113
- : undefined}
114
- {save}
115
- />
89
+ {#if !print}
90
+ <slot name="outline" />
91
+ {/if}
92
+ {#if editable}
93
+ {#if embed === null}
94
+ <Button tooltip="add cover image" command={addCover}
95
+ >+ cover image</Button
96
+ >
116
97
  {:else}
117
- {header}
118
- {#if subtitle}<div class="bookish-subtitle">{subtitle}</div
119
- >{/if}
98
+ <ConfirmButton
99
+ confirm="delete image"
100
+ tooltip="delete cover image"
101
+ command={removeCover}>- cover image</ConfirmButton
102
+ >
120
103
  {/if}
121
- </Title>
122
- <slot name="after" />
123
- {#if tags}
124
- <div
125
- >{#each tags as tag}<span class="bookish-tag">{tag}</span
126
- >{/each}</div
127
- >
128
104
  {/if}
129
- </div>
130
- </header>
105
+ <div bind:this={title} class="bookish-chapter-header-text">
106
+ <slot name="before" />
107
+ <Title>
108
+ {#if editable && save}
109
+ <TextEditor
110
+ {label}
111
+ text={header + (subtitle ? ': ' + subtitle : '')}
112
+ placeholder="title"
113
+ valid={(text) =>
114
+ text.length === 0
115
+ ? 'Titles have to be at least one character long.'
116
+ : undefined}
117
+ {save}
118
+ />
119
+ {:else}
120
+ {header}
121
+ {#if subtitle}<div class="bookish-subtitle">{subtitle}</div
122
+ >{/if}
123
+ {/if}
124
+ </Title>
125
+ <slot name="after" />
126
+ {#if tags}
127
+ <div
128
+ >{#each tags as tag}<span class="bookish-tag">{tag}</span
129
+ >{/each}</div
130
+ >
131
+ {/if}
132
+ </div>
133
+ </header>
134
+ {/key}
131
135
 
132
136
  <style>
133
137
  .bookish-subtitle {
@@ -24,7 +24,7 @@ onMount(() => {
24
24
  media?.notify(updateImages);
25
25
  return () => media?.stopNotifying(updateImages);
26
26
  });
27
- $: unused = images?.filter((image) => embeds.find((embed) => embed.getURL() === image.url) === undefined);
27
+ $: unused = images?.filter((image) => !embeds.some((embed) => image.url.includes(embed.embed.getURL())));
28
28
  </script>
29
29
 
30
30
  {#if $edition}
@@ -59,9 +59,10 @@ $: unused = images?.filter((image) => embeds.find((embed) => embed.getURL() ===
59
59
  {:else}
60
60
  <p>These are the images in the book:</p>
61
61
  {/if}
62
- {#each embeds as embed}
62
+ {#each embeds as { embed, chapterID }}
63
63
  {#if !embed.isVideo()}
64
64
  <MediaPreview
65
+ {chapterID}
65
66
  url={embed.isLocal()
66
67
  ? `${$base}/${embed.getSmallURL()}`
67
68
  : embed.getSmallURL()}
@@ -89,10 +90,10 @@ $: unused = images?.filter((image) => embeds.find((embed) => embed.getURL() ===
89
90
  be used in other editions of this book.
90
91
  </Instructions>
91
92
  {#each unused as image}
92
- <MediaPreview url={image.url} alt={''}>
93
+ <MediaPreview chapterID={undefined} url={image.url} alt={''}>
93
94
  <ConfirmButton
94
95
  tooltip="delete unused image"
95
- confirm="delete image?"
96
+ confirm="delete image"
96
97
  command={() =>
97
98
  media
98
99
  ?.remove(image)
@@ -1,5 +1,9 @@
1
- <script>export let url;
1
+ <script>import Link from '../Link.svelte';
2
+ import { getBase } from './Contexts';
3
+ export let url;
2
4
  export let alt;
5
+ export let chapterID;
6
+ let base = getBase();
3
7
  let loaded;
4
8
  let urlChecked;
5
9
  $: {
@@ -18,7 +22,9 @@ $: {
18
22
 
19
23
  <figure class={'media-preview'}>
20
24
  {#if loaded}
21
- <img src={url.startsWith('http') ? url : `${url}`} {alt} />
25
+ <Link to={`${$base}/${chapterID}`} linked={chapterID !== undefined}
26
+ ><img src={url.startsWith('http') ? url : `${url}`} {alt} /></Link
27
+ >
22
28
  {:else if loaded === null}
23
29
  <div class="missing">missing image</div>
24
30
  {/if}
@@ -3,6 +3,7 @@ declare const __propDef: {
3
3
  props: {
4
4
  url: string;
5
5
  alt: string;
6
+ chapterID: string | undefined;
6
7
  };
7
8
  events: {
8
9
  [evt: string]: CustomEvent<any>;
@@ -2,10 +2,12 @@
2
2
  import Loading from './Loading.svelte';
3
3
  export let title;
4
4
  export let afterLoaded = undefined;
5
- let loaded = false;
5
+ let fontsLoaded = false;
6
+ let imagesLoaded = false;
6
7
  let lastHeight = 0;
7
8
  let intervalID = null;
8
9
  let mountTime = Date.now();
10
+ $: loaded = fontsLoaded && imagesLoaded;
9
11
  function watchLoading() {
10
12
  const bodyHeight = document.body.clientHeight;
11
13
  // If the height hasn't changed and images are loaded OR it's been 2 seconds, load.
@@ -14,7 +16,7 @@ function watchLoading() {
14
16
  // Stop watching the images
15
17
  stopWatching();
16
18
  // Set to loaded to hide the overlay and notify callback if there is one.
17
- loaded = true;
19
+ imagesLoaded = true;
18
20
  if (afterLoaded)
19
21
  afterLoaded();
20
22
  }
@@ -29,6 +31,7 @@ onMount(() => {
29
31
  // Watch the height of the document and wait until it's been stable for a while before scrolling
30
32
  // to any targets. We use the data below to monitor the document height over time.
31
33
  intervalID = setInterval(() => watchLoading(), 50);
34
+ document.fonts.ready.then(() => (fontsLoaded = true));
32
35
  return () => stopWatching();
33
36
  });
34
37
  function imagesAreLoaded() {
@@ -45,7 +48,7 @@ function imagesAreLoaded() {
45
48
  <meta name="description" content={title} />
46
49
  </svelte:head>
47
50
 
48
- <section class={'bookish-page' + (loaded ? ' loaded' : '')}>
51
+ <section class={'bookish-page' + (imagesLoaded ? ' loaded' : '')}>
49
52
  <slot />
50
53
  </section>
51
54
  <!-- Overlay loading feedback if loading, but still render the page. -->
@@ -249,7 +249,7 @@ function move(field, direction) {
249
249
  {#if edit && editable && !reference.short}
250
250
  <ConfirmButton
251
251
  tooltip="delete reference {reference.title}"
252
- confirm="delete reference..."
252
+ confirm="delete reference"
253
253
  command={() =>
254
254
  $edition
255
255
  ? edition.set($edition.withoutReference(reference.citationID))
@@ -83,7 +83,7 @@ $: {
83
83
 
84
84
  <p>
85
85
  Type a word—just a single word—and we'll show its occurrences in
86
- this book:
86
+ this book's chapters:
87
87
  </p>
88
88
 
89
89
  <input
@@ -273,10 +273,7 @@ onMount(() => {
273
273
 
274
274
  <!-- Book citation -->
275
275
  <PageParagraph>
276
- {$edition
277
- .getAuthors()
278
- .map((author) => Parser.parseFormat($edition, author).toText())
279
- .join(', ')} ({new Date().getFullYear()}).
276
+ {$edition.getAuthorsText()} ({new Date().getFullYear()}).
280
277
  <em>{$edition.getTitle()}</em>. {bookURL},
281
278
  <em>retrieved {new Date().toLocaleDateString('en-US')}</em>.
282
279
  </PageParagraph>
@@ -76,7 +76,7 @@ $: chapterURL =
76
76
  >
77
77
  <ConfirmButton
78
78
  tooltip="delete chapter {title}"
79
- confirm="delete?"
79
+ confirm="delete chapter"
80
80
  command={() =>
81
81
  chapter && $edition
82
82
  ? edition.set(
@@ -87,7 +87,7 @@ function getEmptyGroup(group) {
87
87
  {:else}
88
88
  <ConfirmButton
89
89
  tooltip="change theme to the default"
90
- confirm="delete your theme?"
90
+ confirm="delete theme"
91
91
  command={() =>
92
92
  $edition
93
93
  ? edition.set($edition.withTheme(null))
@@ -2,6 +2,7 @@ import type { StorageReference } from 'firebase/storage';
2
2
  import type Book from './Book';
3
3
  export type Image = {
4
4
  url: string;
5
+ thumb: string;
5
6
  ref: StorageReference;
6
7
  };
7
8
  export default class BookMedia {
@@ -17,6 +18,8 @@ export default class BookMedia {
17
18
  /** Get a listing of all of the images for the book. */
18
19
  cacheImages(): Promise<Image[]>;
19
20
  retrieveImages(): Promise<(Image | undefined)[]>;
21
+ stripTokenFromURL(url: string): string;
22
+ makeThumbnailURL(url: string): string;
20
23
  getThumbnailURL(url: string, thumbnailRef: StorageReference, error: (message: string) => void, finished: (url: string, thumbnailURL: string) => void, attempt?: number): void;
21
24
  upload(file: File, _: (progress: number) => void, errorHandler: (message: string) => void, finishedHandler: (url: string, thumbnailURL: string) => void): void;
22
25
  remove(image: Image): Promise<Image[] | undefined>;
@@ -1,5 +1,4 @@
1
1
  import { deleteObject, getDownloadURL, listAll, ref, uploadBytes, } from 'firebase/storage';
2
- import { v4 as uuidv4 } from 'uuid';
3
2
  import { storage } from '../Firebase';
4
3
  export default class BookMedia {
5
4
  book;
@@ -43,7 +42,8 @@ export default class BookMedia {
43
42
  // Find all the prefixes and items.
44
43
  return listAll(listRef).then((res) => Promise.all(res.items.map((itemRef) => getDownloadURL(itemRef).then((url) => {
45
44
  const image = {
46
- url: url,
45
+ url,
46
+ thumb: this.makeThumbnailURL(url),
47
47
  description: '',
48
48
  ref: itemRef,
49
49
  };
@@ -53,6 +53,22 @@ export default class BookMedia {
53
53
  return image;
54
54
  }))));
55
55
  }
56
+ stripTokenFromURL(url) {
57
+ const tokenIndex = url.indexOf('&token=');
58
+ return tokenIndex >= 0 ? (url = url.substring(0, tokenIndex)) : url;
59
+ }
60
+ // Construct a thumbnail URL from the base URL.
61
+ makeThumbnailURL(url) {
62
+ // The structure of the URL is
63
+ // https://[firebase-domain]/v0/b/[projectid].appspot.com/o/images%2F[bookID]%2F[imagename]?alt=media
64
+ // And we want
65
+ // https://[firebase-domain]/v0/b/[projectid].appspot.com/o/images%2F[bookID]%2Fthumbnails%2F[imagename]?alt=media
66
+ const bookID = this.book.getID();
67
+ const bookIDIndex = url.indexOf(bookID);
68
+ return bookIDIndex >= 0
69
+ ? `${url.substring(0, bookIDIndex)}${bookID}%2Fthumbnails${url.substring(bookIDIndex + bookID.length)}`
70
+ : url;
71
+ }
56
72
  getThumbnailURL(url, thumbnailRef, error, finished, attempt = 0) {
57
73
  getDownloadURL(thumbnailRef)
58
74
  .then((thumbnail) => finished(url, thumbnail))
@@ -64,9 +80,10 @@ export default class BookMedia {
64
80
  upload(file, _, errorHandler, finishedHandler) {
65
81
  if (storage === undefined)
66
82
  return;
83
+ // We use the file name given, but append a unique string to avoid collisions.
84
+ const imageName = file.name;
67
85
  // The canonical path format for Bookish images in the store is image/{bookid}/{imageid}
68
- // where {bookid} is the Firestore ID of the book being edited and {imageid} is just a random id.
69
- const imageName = `image-${uuidv4()}`;
86
+ // where {bookid} is the Firestore document ID of the book being edited and {imageid} is just a random id.
70
87
  const imageRef = ref(storage, `${this.getImagePath()}/${imageName}`);
71
88
  const thumbnailRef = ref(storage, `${this.getImagePath()}/thumbnails/${imageName}`);
72
89
  uploadBytes(imageRef, file)
@@ -74,7 +91,7 @@ export default class BookMedia {
74
91
  getDownloadURL(snapshot.ref)
75
92
  // After we get the download URL for the uploaded image,
76
93
  // try to get the thumbnail URL for the resized image on the server.
77
- .then((url) => this.getThumbnailURL(url, thumbnailRef, errorHandler, finishedHandler))
94
+ .then((url) => finishedHandler(this.stripTokenFromURL(url), this.makeThumbnailURL(this.stripTokenFromURL(url))))
78
95
  .catch(() => errorHandler('Unable to get image URL.'));
79
96
  })
80
97
  .then(() => this.cacheImages())
@@ -107,6 +107,7 @@ export default class Edition {
107
107
  withTheme(theme: Theme | null): Edition;
108
108
  withThemeValue(group: string, name: string, value: string): Edition;
109
109
  getAuthors(): string[];
110
+ getAuthorsText(): string;
110
111
  withAuthors(authors: string[]): Edition;
111
112
  withAuthor(name: string): Edition;
112
113
  withAuthorName(index: number, name: string): Edition;
@@ -130,7 +131,10 @@ export default class Edition {
130
131
  getBookIndex(): Record<string, Set<string>>;
131
132
  getNextChapterID(chapterID: string): string | null;
132
133
  getPreviousChapterID(chapterID: string): string | null;
133
- getEmbeds(): EmbedNode[];
134
+ getEmbeds(): {
135
+ embed: EmbedNode;
136
+ chapterID: string | undefined;
137
+ }[];
134
138
  isLatestEdition(book: Book): boolean;
135
139
  isLatestPublishedEdition(book: Book): boolean;
136
140
  getEditionNumber(): number;
@@ -59,7 +59,7 @@ export default class Edition {
59
59
  static fromJSON(editionRef, spec) {
60
60
  // Copy all of the specification metadata to fields.
61
61
  // Choose suitable defaults if the spec is lacking a field.
62
- return new Edition(spec.bookRef, editionRef, spec.uids, spec.title, spec.authors, spec.number, spec.summary, spec.published, spec.images, spec.description, spec.chapters.map((chap) => Chapter.fromJSON(chap, '')), spec.license, spec.acknowledgements, spec.tags ?? [], spec.sources ?? {}, spec.references ?? {}, spec.symbols ?? {}, spec.glossary ?? {}, spec.theme, spec.base ?? '', spec.active);
62
+ return new Edition(spec.bookRef, editionRef, spec.uids, spec.title, spec.authors, spec.number, spec.summary, spec.published, spec.images, spec.description, spec.chapters.map((chap) => Chapter.fromJSON(chap, '')), spec.license, spec.acknowledgements, spec.tags ?? [], spec.sources ?? {}, spec.references ?? {}, spec.symbols ?? {}, spec.glossary ?? {}, spec.theme, spec.base, spec.active);
63
63
  }
64
64
  static parseReference(citationID, ref, edition, short = false) {
65
65
  if (typeof ref === 'string')
@@ -99,7 +99,7 @@ export default class Edition {
99
99
  symbols: Object.assign({}, this.symbols),
100
100
  glossary: Object.assign({}, this.glossary),
101
101
  theme: this.theme,
102
- base: this.base,
102
+ base: this.base && this.base.length > 0 ? this.base : null,
103
103
  uids: this.uids.slice(),
104
104
  chapteruids: this.getChapterUIDS(),
105
105
  active: this.active,
@@ -340,6 +340,11 @@ export default class Edition {
340
340
  getAuthors() {
341
341
  return [...this.authors];
342
342
  }
343
+ getAuthorsText() {
344
+ return this.getAuthors()
345
+ .map((author) => Parser.parseFormat(this, author).toText())
346
+ .join(', ');
347
+ }
343
348
  withAuthors(authors) {
344
349
  return new Edition(this.bookRef, this.editionRef, this.uids, this.title, authors, this.number, this.summary, this.published, this.images, this.description, this.chapters, this.license, this.acknowledgements, this.tags, this.sources, this.references, this.symbols, this.glossary, this.theme, this.base, this.active);
345
350
  }
@@ -563,18 +568,18 @@ export default class Edition {
563
568
  if (cover) {
564
569
  let coverNode = Parser.parseEmbed(this, cover);
565
570
  if (coverNode instanceof EmbedNode)
566
- embeds.push(coverNode);
571
+ embeds.push({ embed: coverNode, chapterID: undefined });
567
572
  }
568
573
  // Add the cover and images from each chapter.
569
574
  this.getChapters().forEach((c) => {
570
575
  const image = c.getImage();
571
576
  let cover = image === null ? undefined : Parser.parseEmbed(this, image);
572
577
  if (cover && cover instanceof EmbedNode)
573
- embeds.push(cover);
578
+ embeds.push({ embed: cover, chapterID: c.id });
574
579
  // Get the chapter body's embeds
575
580
  let bodyEmbeds = c?.getAST(this)?.getEmbeds();
576
581
  if (bodyEmbeds)
577
- bodyEmbeds.forEach((embed) => embeds.push(embed));
582
+ bodyEmbeds.forEach((embed) => embeds.push({ embed, chapterID: c.id }));
578
583
  });
579
584
  // Add the back matter covers
580
585
  let backmatter = ['references', 'glossary', 'index', 'search', 'media'];
@@ -583,7 +588,7 @@ export default class Edition {
583
588
  if (img) {
584
589
  let image = Parser.parseEmbed(this, img);
585
590
  if (image instanceof EmbedNode)
586
- embeds.push(image);
591
+ embeds.push({ embed: image, chapterID: id });
587
592
  }
588
593
  });
589
594
  return embeds;
@@ -62,7 +62,6 @@ const CommonWords = [
62
62
  'other',
63
63
  'our',
64
64
  'out',
65
- 'people',
66
65
  'say',
67
66
  'see',
68
67
  'she',
@@ -22,6 +22,7 @@ export default class ChapterNode extends BlocksNode {
22
22
  getEmbeds(): EmbedNode[];
23
23
  getComments(): CommentNode[];
24
24
  getLabels(): LabelNode[];
25
+ getFirstParagraph(): ParagraphNode | undefined;
25
26
  hasLabel(labelID: string): boolean;
26
27
  getCitationNumber(citationID: string): number | null;
27
28
  toText(): string;
@@ -45,6 +45,9 @@ export default class ChapterNode extends BlocksNode {
45
45
  getLabels() {
46
46
  return this.getNodes().filter((n) => n instanceof LabelNode);
47
47
  }
48
+ getFirstParagraph() {
49
+ return this.getBlocks().find((b) => b instanceof ParagraphNode);
50
+ }
48
51
  hasLabel(labelID) {
49
52
  return (this.getLabels().find((label) => label.getMeta() === labelID) !==
50
53
  undefined);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "bookish-press",
3
3
  "private": false,
4
- "version": "0.5.24",
4
+ "version": "0.5.26",
5
5
  "type": "module",
6
6
  "description": "A platform for reading and writing readable, accessible, beautiful online books.",
7
7
  "license": "MIT",
@@ -22,12 +22,14 @@
22
22
  "//stage": "echo 'switch to the dev project, deploy, relying on Firebase's predeploy hook to run the build script above before release'",
23
23
  "stage": "firebase use dev && firebase deploy",
24
24
  "//release": "echo 'switch to the prod project, deploy, relying on Firebase's predeploy hook to run the build script above before release'",
25
- "release": "firebase use prod && firebase deploy"
25
+ "release": "firebase use prod && firebase deploy",
26
+ "//merge": "merge dev into main, push to remote",
27
+ "merge": "git checkout main && git merge dev && git push origin main && git checkout dev"
26
28
  },
27
29
  "devDependencies": {
28
30
  "@sveltejs/adapter-static": "^2.0.1",
29
- "@sveltejs/kit": "^1.7.2",
30
- "@sveltejs/package": "^2.0.0",
31
+ "@sveltejs/kit": "^1.11",
32
+ "@sveltejs/package": "^2.0.2",
31
33
  "@types/prismjs": "^1.26.0",
32
34
  "@types/smoothscroll-polyfill": "^0.3.1",
33
35
  "@types/uuid": "^9.0.0",
@@ -39,7 +41,7 @@
39
41
  "tslib": "^2.5.0",
40
42
  "typescript": "^4.9.5",
41
43
  "vite": "^4.1.2",
42
- "vitest": "^0.28.5"
44
+ "vitest": "^0.29.2"
43
45
  },
44
46
  "dependencies": {
45
47
  "ajv": "^8.12.0",