@memorysmithapp/markdown-renderer 0.3.2 → 0.5.0

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 (59) hide show
  1. package/README.md +50 -4
  2. package/dist/Markdown.d.ts +6 -2
  3. package/dist/Markdown.js +126 -17
  4. package/dist/NoteActions.d.ts +9 -0
  5. package/dist/NoteActions.js +68 -0
  6. package/dist/NoteBody.d.ts +32 -2
  7. package/dist/NoteBody.js +74 -7
  8. package/dist/edit/AttachDialog.d.ts +23 -0
  9. package/dist/edit/AttachDialog.js +126 -0
  10. package/dist/edit/ContextMenu.d.ts +29 -0
  11. package/dist/edit/ContextMenu.js +95 -0
  12. package/dist/edit/LanguageControl.d.ts +19 -0
  13. package/dist/edit/LanguageControl.js +124 -0
  14. package/dist/edit/NoteEditor.d.ts +44 -0
  15. package/dist/edit/NoteEditor.js +520 -0
  16. package/dist/edit/SaveNoticeDialog.d.ts +15 -0
  17. package/dist/edit/SaveNoticeDialog.js +41 -0
  18. package/dist/edit/analyze.d.ts +150 -0
  19. package/dist/edit/analyze.js +268 -0
  20. package/dist/edit/blocks.d.ts +97 -0
  21. package/dist/edit/blocks.js +481 -0
  22. package/dist/edit/commands.d.ts +78 -0
  23. package/dist/edit/commands.js +257 -0
  24. package/dist/edit/frontmatter.d.ts +45 -0
  25. package/dist/edit/frontmatter.js +142 -0
  26. package/dist/edit/index.d.ts +5 -0
  27. package/dist/edit/index.js +7 -0
  28. package/dist/edit/live.d.ts +28 -0
  29. package/dist/edit/live.js +675 -0
  30. package/dist/edit/saves.d.ts +48 -0
  31. package/dist/edit/saves.js +73 -0
  32. package/dist/edit/table.d.ts +58 -0
  33. package/dist/edit/table.js +185 -0
  34. package/dist/file-types.d.ts +30 -0
  35. package/dist/file-types.js +70 -0
  36. package/dist/highlight.d.ts +21 -7
  37. package/dist/highlight.js +56 -7
  38. package/dist/host.d.ts +82 -3
  39. package/dist/host.js +16 -0
  40. package/dist/index.d.ts +6 -4
  41. package/dist/index.js +1 -0
  42. package/dist/mode-icons.d.ts +6 -0
  43. package/dist/mode-icons.js +11 -0
  44. package/dist/prism-grammars.d.ts +18 -0
  45. package/dist/prism-grammars.js +305 -0
  46. package/dist/remark-callouts.d.ts +6 -0
  47. package/dist/remark-callouts.js +2 -2
  48. package/dist/remark-ring.d.ts +5 -0
  49. package/dist/remark-ring.js +25 -15
  50. package/dist/slots.js +17 -1
  51. package/dist/strings.d.ts +129 -1
  52. package/dist/strings.js +127 -1
  53. package/dist/task-saves.d.ts +35 -0
  54. package/dist/task-saves.js +86 -0
  55. package/dist/theme.d.ts +47 -0
  56. package/dist/theme.js +2 -2
  57. package/package.json +14 -2
  58. package/structure.css +355 -0
  59. package/theme-default.css +1099 -0
@@ -125,26 +125,36 @@ export function remarkMathDollarRule() {
125
125
  const { start, end } = child.position ?? {};
126
126
  if (start?.offset === undefined || end?.offset === undefined)
127
127
  return child;
128
- const raw = source.slice(start.offset, end.offset);
129
- const inner = raw.replace(/^\$+/, '').replace(/\$+$/, '');
130
- if (inner.length === 0)
131
- return { type: 'text', value: raw };
132
- // The outside edges: what sits just inside each delimiter.
133
- if (/^\s/.test(inner) || /\s$/.test(inner))
134
- return { type: 'text', value: raw };
135
- // The inside edges: what sits just OUTSIDE each delimiter, which is
136
- // the half `R$100 e o frete R$200` is decided by.
137
- const before = start.offset > 0 ? (source[start.offset - 1] ?? '') : '';
138
- const after = source[end.offset] ?? '';
139
- if (before !== '' && /[0-9A-Za-z]/.test(before))
140
- return { type: 'text', value: raw };
141
- if (after !== '' && /[0-9]/.test(after))
142
- return { type: 'text', value: raw };
128
+ if (breaksDollarRule(source, start.offset, end.offset)) {
129
+ return { type: 'text', value: source.slice(start.offset, end.offset) };
130
+ }
143
131
  return child;
144
132
  });
145
133
  });
146
134
  };
147
135
  }
136
+ /**
137
+ * Whether the inline formula the source holds between these offsets, delimiters included, breaks
138
+ * the dollar rule of §7.8 and is text: the reading surface and the editor decide it alike.
139
+ */
140
+ export function breaksDollarRule(source, start, end) {
141
+ const raw = source.slice(start, end);
142
+ const inner = raw.replace(/^\$+/, '').replace(/\$+$/, '');
143
+ if (inner.length === 0)
144
+ return true;
145
+ // The outside edges: what sits just inside each delimiter.
146
+ if (/^\s/.test(inner) || /\s$/.test(inner))
147
+ return true;
148
+ // The inside edges: what sits just OUTSIDE each delimiter, which is
149
+ // the half `R$100 e o frete R$200` is decided by.
150
+ const before = start > 0 ? (source[start - 1] ?? '') : '';
151
+ const after = source[end] ?? '';
152
+ if (before !== '' && /[0-9A-Za-z]/.test(before))
153
+ return true;
154
+ if (after !== '' && /[0-9]/.test(after))
155
+ return true;
156
+ return false;
157
+ }
148
158
  /** Cuts a text node on a pattern, keeping the parts between the matches. */
149
159
  function split(value, pattern, make) {
150
160
  const parts = [];
package/dist/slots.js CHANGED
@@ -8,7 +8,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  * interpreted when the theme fills it. Nothing a slot is given lets it reach
9
9
  * the source: what it draws is decided before it is called.
10
10
  */
11
- import { Children, createContext, createElement, Fragment, isValidElement, useContext, } from 'react';
11
+ import { Children, createContext, createElement, Fragment, isValidElement, useContext, useState, } from 'react';
12
12
  import { useHost } from './host.js';
13
13
  /** The slot a theme fills, or `undefined`. */
14
14
  export function useSlot(name) {
@@ -55,8 +55,24 @@ function Callout({ kind, fold, children }) {
55
55
  const body = parts.filter((_, index) => index !== at);
56
56
  return drawSlot(slot, { kind, fold, title, icon, children: body });
57
57
  }
58
+ if (fold) {
59
+ const body = parts.filter((_, index) => index !== at);
60
+ return (_jsx(FoldingCallout, { kind: kind, fold: fold, icon: icon, body: body, children: title }));
61
+ }
58
62
  return (_jsx("div", { className: "callout", "data-callout": kind, ...(fold ? { 'data-fold': fold } : {}), children: parts.map((part, index) => (index === at ? _jsx(Fragment, { children: titled }, "title") : part)) }));
59
63
  }
64
+ /**
65
+ * A callout marked `+` or `-`, which the default theme folds (§7, #83): a disclosure whose title
66
+ * is its control, open at first for `+` and closed for `-`, where only the title shows. A callout
67
+ * with no mark keeps MemorySmith's markup, which draws every callout open.
68
+ */
69
+ function FoldingCallout({ kind, fold, icon, body, children }) {
70
+ const { strings } = useHost();
71
+ const [open, setOpen] = useState(fold === '+');
72
+ // A callout with no title is named by its type's word, so the control always says what it opens.
73
+ const words = children ?? strings.callouts[kind.toLowerCase()] ?? kind;
74
+ return (_jsxs("details", { className: "callout", "data-callout": kind, "data-fold": fold, open: open, onToggle: (event) => setOpen(event.currentTarget.open), children: [_jsxs("summary", { className: icon === null ? 'callout-title' : 'callout-title has-icon', children: [icon !== null && (_jsx("span", { className: "callout-icon", "aria-hidden": "true", children: icon })), _jsx(CalloutTitleText, { children: words })] }), body] }));
75
+ }
60
76
  /** A `div` of the tree: a callout or its title, which `remarkCallouts` marks, or any other. */
61
77
  export function MarkdownDiv({ className, children, node: _node, ...rest }) {
62
78
  const classes = className?.split(/\s+/) ?? [];
package/dist/strings.d.ts CHANGED
@@ -25,6 +25,11 @@ export interface NoteStrings {
25
25
  readonly refusedLink: string;
26
26
  /** The title of an embed naming a file the notebook does not keep. */
27
27
  readonly attachmentMissing: string;
28
+ /** A card's ways to a file: open it where a browser shows it, and save it (#77). */
29
+ readonly attachmentOpen: string;
30
+ readonly attachmentDownload: string;
31
+ /** What each type MemorySmith keeps is called, by media type; a type left out is named by its media type. */
32
+ readonly fileKinds: Readonly<Record<string, string>>;
28
33
  /** The verb of the button that copies a block of code. */
29
34
  readonly copy: string;
30
35
  /** What that button says once the block is copied. */
@@ -47,6 +52,20 @@ export interface NoteStrings {
47
52
  readonly embedSectionMissing: (section: string) => string;
48
53
  /** The caption of a note that has no name. */
49
54
  readonly unnamedNote: string;
55
+ /** What a task item says while the host saves its tick. */
56
+ readonly updating: string;
57
+ /** What is said, to whoever hears the page, once a tick is saved. */
58
+ readonly saved: string;
59
+ /** What a task item says for a moment when the host failed to save its tick. */
60
+ readonly notSaved: string;
61
+ /** The switch to edit mode, which says the action. */
62
+ readonly edit: string;
63
+ /** The switch back to reading, which says the action. */
64
+ readonly view: string;
65
+ /** What edit mode says of a note it cannot keep byte for byte, and so opens read-only. */
66
+ readonly editUnavailable: string;
67
+ /** The words of the editor: its toolbar, its forms and its controls. */
68
+ readonly editor: EditorWords;
50
69
  /** What a region says while it waits for the host. */
51
70
  readonly loading: string;
52
71
  /** What a region says when the host failed to answer. */
@@ -56,7 +75,116 @@ export interface NoteStrings {
56
75
  /** The button that dismisses the choice. */
57
76
  readonly cancel: string;
58
77
  }
78
+ /** The words of the editor (`./edit`), each a label a control shows or is named by. */
79
+ export interface EditorWords {
80
+ readonly toolbar: string;
81
+ /** The control that gives back the note as edit mode opened it (#67). */
82
+ readonly revert: string;
83
+ readonly undo: string;
84
+ readonly redo: string;
85
+ readonly paragraph: string;
86
+ readonly heading: string;
87
+ readonly bold: string;
88
+ readonly italic: string;
89
+ readonly strikethrough: string;
90
+ readonly highlight: string;
91
+ readonly code: string;
92
+ readonly link: string;
93
+ readonly wikilink: string;
94
+ readonly bulletList: string;
95
+ readonly orderedList: string;
96
+ readonly taskList: string;
97
+ readonly quote: string;
98
+ readonly callout: string;
99
+ readonly table: string;
100
+ readonly codeBlock: string;
101
+ readonly math: string;
102
+ readonly diagram: string;
103
+ readonly rule: string;
104
+ readonly footnote: string;
105
+ readonly comment: string;
106
+ /** The toolbar's button that attaches a file, and what it says while the host keeps it or failed (#75). */
107
+ readonly attach: string;
108
+ readonly attaching: string;
109
+ readonly attachFailed: string;
110
+ /** Said after the host's step while more files wait their turn (#88). */
111
+ readonly attachQueued: (count: number) => string;
112
+ /** A code block's language, chosen at its top right (#84). */
113
+ readonly language: string;
114
+ readonly plainText: string;
115
+ readonly mermaidDiagram: string;
116
+ readonly searchLanguages: string;
117
+ readonly noLanguage: string;
118
+ readonly notHighlighted: string;
119
+ /** The editing menu of a right-click (#81): its name, its groups, and the clipboard's verbs. */
120
+ readonly menu: string;
121
+ readonly menuFormat: string;
122
+ readonly menuInsert: string;
123
+ readonly menuEdit: string;
124
+ readonly cut: string;
125
+ readonly paste: string;
126
+ /** The attach dialog (#78): its title, its three ways in, and what each one asks and says. */
127
+ readonly attachTitle: string;
128
+ readonly fromComputer: string;
129
+ readonly fromNotebook: string;
130
+ readonly fromWeb: string;
131
+ readonly chooseFiles: string;
132
+ readonly fileName: string;
133
+ readonly fileDescription: string;
134
+ readonly attachConfirm: string;
135
+ readonly searchFiles: string;
136
+ readonly noFiles: string;
137
+ readonly noMatch: string;
138
+ readonly webAddress: string;
139
+ readonly webName: string;
140
+ readonly webInsert: string;
141
+ readonly webInvalid: string;
142
+ /** The control that shows a block as the text it is written in. */
143
+ readonly source: string;
144
+ readonly title: string;
145
+ readonly addProperties: string;
146
+ readonly addProperty: string;
147
+ readonly removeProperty: string;
148
+ readonly propertyKey: string;
149
+ readonly propertyValue: string;
150
+ /** The type of a property, chosen at its key, and each type's word (#70, #71). */
151
+ readonly propertyType: string;
152
+ readonly typeText: string;
153
+ readonly typeList: string;
154
+ readonly typeBoolean: string;
155
+ readonly typeDate: string;
156
+ readonly addItem: string;
157
+ readonly removeItem: string;
158
+ readonly calloutType: string;
159
+ readonly fold: string;
160
+ readonly foldNone: string;
161
+ readonly foldOpen: string;
162
+ readonly foldClosed: string;
163
+ readonly addRow: string;
164
+ readonly addColumn: string;
165
+ readonly removeRow: string;
166
+ readonly removeColumn: string;
167
+ readonly alignLeft: string;
168
+ readonly alignCenter: string;
169
+ readonly alignRight: string;
170
+ /** The buttons beside the switch that show the note's Markdown and copy it, and the dialog (#63, #76). */
171
+ readonly showMarkdown: string;
172
+ readonly copyMarkdown: string;
173
+ readonly sourceTitle: string;
174
+ readonly copy: string;
175
+ readonly copied: string;
176
+ readonly copyFailed: string;
177
+ /** Where the save of the last change stands (#64); `saved` and `notSaved` are the page's own. */
178
+ readonly saving: string;
179
+ readonly retry: string;
180
+ /** Said when the author asked to go back to reading and the last change is not saved. */
181
+ readonly stayInEdit: string;
182
+ }
59
183
  /** The words MemorySmith shows in English, used for every word a host does not give. */
60
184
  export declare const ENGLISH: NoteStrings;
61
185
  /** The host's words over the English ones, key by key. */
62
- export declare function withDefaults(strings: Partial<NoteStrings> | undefined): NoteStrings;
186
+ export declare function withDefaults(strings: NoteStringsInput | undefined): NoteStrings;
187
+ /** The words a host gives: any of them, and any of the editor's. */
188
+ export type NoteStringsInput = Partial<Omit<NoteStrings, 'editor'>> & {
189
+ readonly editor?: Partial<EditorWords>;
190
+ };
package/dist/strings.js CHANGED
@@ -40,6 +40,26 @@ export const ENGLISH = {
40
40
  pendingLink: 'Pending link: the target note does not exist yet.',
41
41
  refusedLink: 'This address is not one the reading surface follows, so the text stays and the link does not.',
42
42
  attachmentMissing: 'This notebook keeps no file under that name.',
43
+ attachmentOpen: 'Open',
44
+ attachmentDownload: 'Download',
45
+ fileKinds: {
46
+ 'image/png': 'PNG image',
47
+ 'image/jpeg': 'JPEG image',
48
+ 'image/webp': 'WebP image',
49
+ 'image/gif': 'GIF image',
50
+ 'image/svg+xml': 'SVG image',
51
+ 'application/pdf': 'PDF document',
52
+ 'audio/mpeg': 'MP3 audio',
53
+ 'audio/wav': 'WAV audio',
54
+ 'audio/ogg': 'Ogg audio',
55
+ 'audio/webm': 'WebM audio',
56
+ 'video/mp4': 'MP4 video',
57
+ 'video/webm': 'WebM video',
58
+ 'video/quicktime': 'QuickTime video',
59
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word document',
60
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel spreadsheet',
61
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'PowerPoint presentation',
62
+ },
43
63
  copy: 'Copy',
44
64
  copied: 'Copied',
45
65
  remoteImages: (hosts) => hosts.length === 1
@@ -57,6 +77,105 @@ export const ENGLISH = {
57
77
  : `No note is named “${target}”, so it matched ${count} notes that carry it as an alias — and one of them gives the link up the day somebody writes a note under this name.`,
58
78
  embedSectionMissing: (section) => `The section "${section}" is not in this note.`,
59
79
  unnamedNote: 'Unnamed note',
80
+ updating: 'Updating…',
81
+ saved: 'Saved',
82
+ notSaved: 'Not saved',
83
+ edit: 'Edit',
84
+ view: 'View',
85
+ editUnavailable: 'This note mixes line endings, which the editor cannot keep byte for byte, so it opens read-only.',
86
+ editor: {
87
+ toolbar: 'Formatting',
88
+ revert: 'Undo all changes',
89
+ undo: 'Undo',
90
+ redo: 'Redo',
91
+ paragraph: 'Paragraph',
92
+ heading: 'Heading',
93
+ bold: 'Bold',
94
+ italic: 'Italic',
95
+ strikethrough: 'Strikethrough',
96
+ highlight: 'Highlight',
97
+ code: 'Code',
98
+ link: 'Link',
99
+ wikilink: 'Link to a note',
100
+ bulletList: 'Bulleted list',
101
+ orderedList: 'Numbered list',
102
+ taskList: 'Task list',
103
+ quote: 'Quotation',
104
+ callout: 'Callout',
105
+ table: 'Table',
106
+ codeBlock: 'Code block',
107
+ math: 'Formula',
108
+ diagram: 'Diagram',
109
+ rule: 'Divider',
110
+ footnote: 'Footnote',
111
+ comment: 'Comment',
112
+ attach: 'Attach a file',
113
+ attaching: 'Attaching…',
114
+ attachFailed: 'The file was not attached',
115
+ attachQueued: (count) => (count === 1 ? '1 more file waiting' : `${count} more files waiting`),
116
+ language: 'Language',
117
+ plainText: 'Plain text',
118
+ mermaidDiagram: 'Mermaid diagram',
119
+ searchLanguages: 'Search languages',
120
+ noLanguage: 'No language matches.',
121
+ notHighlighted: 'not highlighted',
122
+ menu: 'Editing',
123
+ menuFormat: 'Format',
124
+ menuInsert: 'Insert',
125
+ menuEdit: 'Edit',
126
+ cut: 'Cut',
127
+ paste: 'Paste',
128
+ attachTitle: 'Attach',
129
+ fromComputer: 'From the computer',
130
+ fromNotebook: 'From the notebook',
131
+ fromWeb: 'From the web',
132
+ chooseFiles: 'Choose files',
133
+ fileName: 'Name',
134
+ fileDescription: 'Description',
135
+ attachConfirm: 'Attach',
136
+ searchFiles: "Search the notebook's files",
137
+ noFiles: 'The notebook keeps no file yet.',
138
+ noMatch: 'No file matches.',
139
+ webAddress: 'Address',
140
+ webName: 'Name',
141
+ webInsert: 'Insert',
142
+ webInvalid: 'An address on the web starts with https:// or http://.',
143
+ source: 'Source',
144
+ title: 'Title',
145
+ addProperties: 'Add properties',
146
+ addProperty: 'Add a property',
147
+ removeProperty: 'Remove the property',
148
+ propertyKey: 'Key',
149
+ propertyValue: 'Value',
150
+ propertyType: 'Type',
151
+ typeText: 'Text',
152
+ typeList: 'List',
153
+ typeBoolean: 'Yes or no',
154
+ typeDate: 'Date',
155
+ addItem: 'Add',
156
+ removeItem: 'Remove',
157
+ calloutType: 'Type',
158
+ fold: 'Folding',
159
+ foldNone: 'Not foldable',
160
+ foldOpen: 'Folds, open',
161
+ foldClosed: 'Folds, closed',
162
+ addRow: 'Add a row',
163
+ addColumn: 'Add a column',
164
+ removeRow: 'Remove the row',
165
+ removeColumn: 'Remove the column',
166
+ alignLeft: 'Align left',
167
+ alignCenter: 'Center',
168
+ alignRight: 'Align right',
169
+ showMarkdown: 'Show Markdown',
170
+ copyMarkdown: 'Copy Markdown',
171
+ sourceTitle: 'The note in Markdown',
172
+ copy: 'Copy',
173
+ copied: 'Copied',
174
+ copyFailed: 'Could not copy',
175
+ saving: 'Saving…',
176
+ retry: 'Try again',
177
+ stayInEdit: 'Not saved: the note stays in edit',
178
+ },
60
179
  loading: 'Loading…',
61
180
  failed: 'Something went wrong.',
62
181
  close: 'Close',
@@ -64,5 +183,12 @@ export const ENGLISH = {
64
183
  };
65
184
  /** The host's words over the English ones, key by key. */
66
185
  export function withDefaults(strings) {
67
- return { ...ENGLISH, ...strings };
186
+ // The editor's words and the names of the file types are merged one by one too, so a host may
187
+ // translate some and keep the rest.
188
+ return {
189
+ ...ENGLISH,
190
+ ...strings,
191
+ editor: { ...ENGLISH.editor, ...strings?.editor },
192
+ fileKinds: { ...ENGLISH.fileKinds, ...strings?.fileKinds },
193
+ };
68
194
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The saves of the task boxes a reader ticks, as the page shows them.
3
+ *
4
+ * A tick only exists once the host has saved it, and a reader who cannot see
5
+ * that cannot tell which state was kept. So when the host answers a tick with
6
+ * a promise, the item is locked under "Updating…" until it settles: resolved,
7
+ * the lock lifts and the box is what the host's new bytes say; rejected, the
8
+ * box returns to the state its source still holds and the item says "Not
9
+ * saved" for a moment. A host that answers with nothing keeps today's page.
10
+ *
11
+ * The calls are queued: a second tick reaches the host only once the first
12
+ * settled, so a host that applies `toggleTaskAt` to its latest bytes never
13
+ * has two writes of the same note in flight.
14
+ */
15
+ /** Where the save of one box stands, by its ordinal in the note. */
16
+ export type TaskSave = {
17
+ readonly kind: 'pending';
18
+ readonly checked: boolean;
19
+ } | {
20
+ readonly kind: 'failed';
21
+ };
22
+ /** What the host answers a tick with: the promise of its save, or anything else, which locks nothing. */
23
+ export type TaskToggleResult = unknown;
24
+ /** How long "Not saved" stays on an item, in milliseconds. */
25
+ export declare const FAILED_NOTICE_MS = 2500;
26
+ export interface TaskSaves {
27
+ /** The save of the box at this ordinal, or `null` when nothing is in flight or just failed. */
28
+ readonly stateOf: (ordinal: number) => TaskSave | null;
29
+ /** Reports a tick; `checked` is the state the box is being set to. */
30
+ readonly toggle: (ordinal: number, checked: boolean) => void;
31
+ /** What the page last said about a save, for a polite live region; empty when it has nothing to say. */
32
+ readonly announcement: 'updating' | 'saved' | 'notSaved' | '';
33
+ }
34
+ export declare function isThenable(value: unknown): value is PromiseLike<unknown>;
35
+ export declare function useTaskSaves(onToggleTask: ((ordinal: number) => TaskToggleResult) | undefined): TaskSaves;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The saves of the task boxes a reader ticks, as the page shows them.
3
+ *
4
+ * A tick only exists once the host has saved it, and a reader who cannot see
5
+ * that cannot tell which state was kept. So when the host answers a tick with
6
+ * a promise, the item is locked under "Updating…" until it settles: resolved,
7
+ * the lock lifts and the box is what the host's new bytes say; rejected, the
8
+ * box returns to the state its source still holds and the item says "Not
9
+ * saved" for a moment. A host that answers with nothing keeps today's page.
10
+ *
11
+ * The calls are queued: a second tick reaches the host only once the first
12
+ * settled, so a host that applies `toggleTaskAt` to its latest bytes never
13
+ * has two writes of the same note in flight.
14
+ */
15
+ import { useCallback, useEffect, useRef, useState } from 'react';
16
+ /** How long "Not saved" stays on an item, in milliseconds. */
17
+ export const FAILED_NOTICE_MS = 2500;
18
+ export function isThenable(value) {
19
+ return typeof value === 'object' && value !== null && typeof value.then === 'function';
20
+ }
21
+ export function useTaskSaves(onToggleTask) {
22
+ const [states, setStates] = useState(() => new Map());
23
+ const [announcement, setAnnouncement] = useState('');
24
+ // Every save still in flight, and the end of the queue: a settled promise, whatever the outcome.
25
+ const inFlight = useRef(0);
26
+ const tail = useRef(Promise.resolve());
27
+ const timers = useRef(new Set());
28
+ // The latest callback, so a queued tick reaches the host's current one and not the one of its render.
29
+ const latest = useRef(onToggleTask);
30
+ latest.current = onToggleTask;
31
+ useEffect(() => {
32
+ const pending = timers.current;
33
+ return () => {
34
+ for (const timer of pending)
35
+ clearTimeout(timer);
36
+ };
37
+ }, []);
38
+ const put = (ordinal, save) => setStates((all) => {
39
+ const next = new Map(all);
40
+ if (save)
41
+ next.set(ordinal, save);
42
+ else
43
+ next.delete(ordinal);
44
+ return next;
45
+ });
46
+ const track = (ordinal, checked, save) => {
47
+ inFlight.current += 1;
48
+ put(ordinal, { kind: 'pending', checked });
49
+ setAnnouncement('updating');
50
+ tail.current = save.then(() => {
51
+ inFlight.current -= 1;
52
+ put(ordinal, null);
53
+ setAnnouncement('saved');
54
+ }, () => {
55
+ inFlight.current -= 1;
56
+ put(ordinal, { kind: 'failed' });
57
+ setAnnouncement('notSaved');
58
+ const timer = setTimeout(() => {
59
+ timers.current.delete(timer);
60
+ setStates((all) => {
61
+ if (all.get(ordinal)?.kind !== 'failed')
62
+ return all;
63
+ const next = new Map(all);
64
+ next.delete(ordinal);
65
+ return next;
66
+ });
67
+ }, FAILED_NOTICE_MS);
68
+ timers.current.add(timer);
69
+ });
70
+ };
71
+ const toggle = useCallback((ordinal, checked) => {
72
+ const call = () => latest.current?.(ordinal);
73
+ if (inFlight.current === 0) {
74
+ // Nothing ahead of it: the host is asked now, and an answer of nothing draws no lock.
75
+ const result = call();
76
+ if (isThenable(result))
77
+ track(ordinal, checked, Promise.resolve(result));
78
+ return;
79
+ }
80
+ // Something is in flight: the box locks at once, and the host is asked once the queue reaches it.
81
+ track(ordinal, checked, tail.current.then(() => call()));
82
+ // `track` reads only refs and state setters, so the one made on the first render serves every tick.
83
+ }, []);
84
+ const stateOf = useCallback((ordinal) => states.get(ordinal) ?? null, [states]);
85
+ return { stateOf, toggle, announcement };
86
+ }
package/dist/theme.d.ts CHANGED
@@ -21,6 +21,8 @@
21
21
  * is read by the default theme's rules.
22
22
  */
23
23
  import type { ReactNode } from 'react';
24
+ import type { FileShape } from './file-types.js';
25
+ import type { KeptFile } from './host.js';
24
26
  import type { PropertyEntry } from './properties.js';
25
27
  export type Scheme = 'light' | 'dark';
26
28
  /**
@@ -66,6 +68,8 @@ export interface ThemeProperties {
66
68
  readonly iconDanger?: string;
67
69
  readonly iconExample?: string;
68
70
  readonly iconQuote?: string;
71
+ /** The chevron of a callout that folds (#83), a CSS image used as a mask. */
72
+ readonly iconChevron?: string;
69
73
  readonly fontSans?: string;
70
74
  readonly fontDisplay?: string;
71
75
  readonly fontMono?: string;
@@ -194,6 +198,10 @@ export interface TaskBoxSlotProps {
194
198
  readonly enabled: boolean;
195
199
  /** Reports the tick to the host; does nothing when the box is not enabled. */
196
200
  readonly toggle: () => void;
201
+ /** The host is saving a tick of this box: `checked` is the state being written, and the box is not enabled. */
202
+ readonly pending: boolean;
203
+ /** The host failed to save the last tick: `checked` is back to what the source holds. */
204
+ readonly failed: boolean;
197
205
  }
198
206
  /** A wikilink to a note (§5.2). */
199
207
  export interface WikilinkSlotProps {
@@ -232,6 +240,43 @@ export interface PropertiesSlotProps {
232
240
  */
233
241
  readonly value: (text: string) => ReactNode;
234
242
  }
243
+ /**
244
+ * The switch between reading and editing a note, at its top right (#51). It is drawn only when the
245
+ * host made the note `editable`; a theme decides how it looks, never whether editing is allowed.
246
+ */
247
+ export interface ModeSwitchSlotProps {
248
+ /** The mode the note is in. */
249
+ readonly mode: 'view' | 'edit';
250
+ /** The word of the action the switch takes, in the reader's language: `strings.edit` or `strings.view`. */
251
+ readonly label: string;
252
+ /** The default theme's icon of that action: a pencil to edit, an eye to read. */
253
+ readonly icon: ReactNode;
254
+ /** Whether the host asked for the icon alone or the icon and its word. */
255
+ readonly display: 'icon' | 'label';
256
+ /** Switches to the other mode. */
257
+ readonly toggle: () => void;
258
+ }
259
+ /**
260
+ * A file the notebook keeps, where a note embedded it, described by the host's `fileOf` (#77): the
261
+ * slot decides how it looks, the package what it is.
262
+ */
263
+ export interface AttachmentSlotProps {
264
+ /** The name it is kept under, as the embed wrote it. */
265
+ readonly name: string;
266
+ /** What the host said of it, its addresses already through the package's filter. */
267
+ readonly file: KeptFile;
268
+ /** The shape its media type is drawn in: `image`, `audio`, `video` or `card`. */
269
+ readonly shape: FileShape;
270
+ /** Its type in the reader's words, or its media type. */
271
+ readonly kind: string;
272
+ /** Its size in the reader's locale. */
273
+ readonly size: string;
274
+ /** Whether a browser shows it on its own, so a card may offer to open it. */
275
+ readonly opens: boolean;
276
+ /** The dimensions the embed declared (§3.14), in CSS pixels, or `null`. */
277
+ readonly width: number | null;
278
+ readonly height: number | null;
279
+ }
235
280
  /** The slots a theme may fill. Each one left out is drawn as the default theme draws it. */
236
281
  export interface ThemeComponents {
237
282
  readonly callout: (props: CalloutSlotProps) => ReactNode;
@@ -244,11 +289,13 @@ export interface ThemeComponents {
244
289
  readonly heading: (props: HeadingSlotProps) => ReactNode;
245
290
  readonly blockquote: (props: BlockquoteSlotProps) => ReactNode;
246
291
  readonly embed: (props: EmbedSlotProps) => ReactNode;
292
+ readonly attachment: (props: AttachmentSlotProps) => ReactNode;
247
293
  readonly footnotes: (props: FootnotesSlotProps) => ReactNode;
248
294
  readonly taskBox: (props: TaskBoxSlotProps) => ReactNode;
249
295
  readonly wikilink: (props: WikilinkSlotProps) => ReactNode;
250
296
  readonly diagram: (props: DiagramSlotProps) => ReactNode;
251
297
  readonly properties: (props: PropertiesSlotProps) => ReactNode;
298
+ readonly modeSwitch: (props: ModeSwitchSlotProps) => ReactNode;
252
299
  }
253
300
  /** The names of the slots, in the order the documentation lists them. */
254
301
  export declare const THEME_SLOTS: (keyof ThemeComponents)[];
package/dist/theme.js CHANGED
@@ -28,7 +28,7 @@ export const THEME_PROPERTIES = Object.keys({
28
28
  calloutInfo: 1, calloutIdea: 1, calloutDone: 1, calloutWarn: 1, calloutStop: 1, calloutAside: 1, calloutQuote: 1,
29
29
  calloutTitleLinkDecoration: 1, calloutTitleLinkOffset: 1,
30
30
  iconNote: 1, iconInfo: 1, iconSummary: 1, iconTip: 1, iconSuccess: 1, iconQuestion: 1, iconWarning: 1,
31
- iconFailure: 1, iconDanger: 1, iconExample: 1, iconQuote: 1,
31
+ iconFailure: 1, iconDanger: 1, iconExample: 1, iconQuote: 1, iconChevron: 1,
32
32
  fontSans: 1, fontDisplay: 1, fontMono: 1, fontHeading: 1,
33
33
  radius: 1, radiusLarge: 1,
34
34
  fontSize: 1, lineHeight: 1, lineHeightTight: 1, rootLineHeight: 1, paragraphSpacing: 1, headingSpacing: 1,
@@ -50,7 +50,7 @@ export function customPropertyOf(name) {
50
50
  /** The names of the slots, in the order the documentation lists them. */
51
51
  export const THEME_SLOTS = Object.keys({
52
52
  callout: 1, calloutIcon: 1, table: 1, codeBlock: 1, inlineCode: 1, heading: 1, blockquote: 1,
53
- embed: 1, footnotes: 1, taskBox: 1, wikilink: 1, diagram: 1, properties: 1,
53
+ embed: 1, attachment: 1, footnotes: 1, taskBox: 1, wikilink: 1, diagram: 1, properties: 1, modeSwitch: 1,
54
54
  });
55
55
  const NAME = /^[a-z][a-z0-9-]*$/;
56
56
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memorysmithapp/markdown-renderer",
3
- "version": "0.3.2",
3
+ "version": "0.5.0",
4
4
  "description": "A React component that draws a MemorySmith note exactly as MemorySmith's reading page does.",
5
5
  "keywords": [
6
6
  "markdown",
@@ -30,6 +30,8 @@
30
30
  "files": [
31
31
  "dist/*.js",
32
32
  "dist/*.d.ts",
33
+ "dist/edit/*.js",
34
+ "dist/edit/*.d.ts",
33
35
  "styles.css",
34
36
  "structure.css",
35
37
  "theme-default.css",
@@ -46,6 +48,10 @@
46
48
  "types": "./dist/diagnose.d.ts",
47
49
  "default": "./dist/diagnose.js"
48
50
  },
51
+ "./edit": {
52
+ "types": "./dist/edit/index.d.ts",
53
+ "default": "./dist/edit/index.js"
54
+ },
49
55
  "./styles.css": "./styles.css",
50
56
  "./structure.css": "./structure.css",
51
57
  "./theme-default.css": "./theme-default.css"
@@ -56,6 +62,12 @@
56
62
  "react-dom": "^19.0.0"
57
63
  },
58
64
  "dependencies": {
65
+ "@codemirror/autocomplete": "6.20.3",
66
+ "@codemirror/commands": "6.11.1",
67
+ "@codemirror/lang-markdown": "6.5.2",
68
+ "@codemirror/language": "6.12.4",
69
+ "@codemirror/state": "6.7.6",
70
+ "@codemirror/view": "6.43.13",
59
71
  "hast-util-to-jsx-runtime": "2.3.6",
60
72
  "mermaid": "11.17.0",
61
73
  "react-markdown": "9.1.0",
@@ -67,7 +79,7 @@
67
79
  "unified": "11.0.5"
68
80
  },
69
81
  "devDependencies": {
70
- "@memorysmithapp/markdown-spec": "0.3.2",
82
+ "@memorysmithapp/markdown-spec": "0.5.0",
71
83
  "@types/hast": "3.0.5",
72
84
  "@types/react": "19.2.18",
73
85
  "@types/react-dom": "19.2.4",