@memorysmithapp/markdown-renderer 0.7.0 → 0.8.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.
- package/README.md +1 -1
- package/dist/Markdown.d.ts +12 -1
- package/dist/Markdown.js +41 -33
- package/dist/diagnose.js +15 -1
- package/dist/edit/ContextMenu.d.ts +1 -1
- package/dist/edit/ContextMenu.js +1 -1
- package/dist/edit/NoteEditor.js +42 -21
- package/dist/edit/blocks.d.ts +21 -5
- package/dist/edit/blocks.js +328 -56
- package/dist/edit/click-source.d.ts +16 -0
- package/dist/edit/click-source.js +74 -0
- package/dist/edit/heights.js +2 -0
- package/dist/edit/image-size.d.ts +26 -10
- package/dist/edit/image-size.js +52 -17
- package/dist/edit/live.js +25 -11
- package/dist/host.d.ts +15 -1
- package/dist/image-dimensions.d.ts +30 -5
- package/dist/image-dimensions.js +39 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/kept-image.d.ts +20 -0
- package/dist/kept-image.js +47 -0
- package/dist/markdown.js +2 -4
- package/dist/mode-icons.d.ts +3 -0
- package/dist/mode-icons.js +7 -2
- package/dist/strings.d.ts +18 -1
- package/dist/strings.js +10 -1
- package/dist/theme.d.ts +8 -1
- package/package.json +2 -2
- package/structure.css +46 -8
- package/theme-default.css +48 -7
package/dist/edit/blocks.js
CHANGED
|
@@ -17,8 +17,8 @@ import { isTrue, readProperties } from '../properties.js';
|
|
|
17
17
|
import { Transclusion } from '../Transclusion.js';
|
|
18
18
|
import { splitEmbeds } from '../transclusion.js';
|
|
19
19
|
import { wikilinkAddress } from '../wikilink-address.js';
|
|
20
|
-
import { ICON_OWN_SIZE } from '../mode-icons.js';
|
|
21
|
-
import { MAX_IMAGE_WIDTH, readImageSize, resizeImage } from './image-size.js';
|
|
20
|
+
import { ICON_OWN_SIZE, ICON_RATIO_FREE, ICON_RATIO_LOCKED, ICON_RESIZE } from '../mode-icons.js';
|
|
21
|
+
import { describeImage, MAX_IMAGE_WIDTH, readImageSize, resizeImage } from './image-size.js';
|
|
22
22
|
import { addEntry, readFrontmatter, removeEntry, renameKey, setList, setScalar, } from './frontmatter.js';
|
|
23
23
|
import { addColumn, addRow, alignColumn, columnCount, readTable, removeColumn, removeRow, setCell, } from './table.js';
|
|
24
24
|
/** Whether a block's edit is being applied: the block is drawn anew, and the field it held lets the focus go. */
|
|
@@ -54,8 +54,8 @@ export function Snippet({ text }) {
|
|
|
54
54
|
return (_jsx(_Fragment, { children: splitEmbeds(body, host.keeps).map((segment, index) => segment.kind === 'text' ? (_jsx(Markdown, { children: resolveWikilinks(segment.text, (name) => wikilinkAddress(host, name), host.keeps) }, index)) : (_jsx(Transclusion, { target: segment.target, anchor: segment.anchor }, index))) }));
|
|
55
55
|
}
|
|
56
56
|
/** A small button of the editor's own, outside the note's notation. */
|
|
57
|
-
function Control({ label, onClick, children, pressed }) {
|
|
58
|
-
return (_jsx("button", { type: "button", className: "edit-control", title: label, "aria-label": label, "aria-pressed": pressed, "data-ms-control": "", onMouseDown: (event) => event.preventDefault(), onClick: onClick, children: children }));
|
|
57
|
+
function Control({ label, onClick, children, pressed, disabled, focusable = false, }) {
|
|
58
|
+
return (_jsx("button", { type: "button", className: "edit-control", title: label, "aria-label": label, "aria-pressed": pressed, disabled: disabled, "data-ms-control": "", onMouseDown: focusable ? undefined : (event) => event.preventDefault(), onClick: onClick, children: children }));
|
|
59
59
|
}
|
|
60
60
|
// ── Callouts ─────────────────────────────────────────────────────────────────
|
|
61
61
|
/** The types a callout may take from the toolbar: the families the default theme draws. */
|
|
@@ -74,14 +74,67 @@ export function CalloutBlock({ view, text, marker }) {
|
|
|
74
74
|
return (_jsxs("div", { className: "edit-block edit-callout", children: [_jsxs("div", { className: "edit-block-controls", "data-ms-control": "", children: [_jsx("select", { "aria-label": words.calloutType, title: words.calloutType, value: marker.type, onChange: (event) => rewrite(event.target.value, marker.fold), children: types.map((type) => (_jsx("option", { value: type, children: strings.callouts[type] ?? type }, type))) }), _jsxs("select", { "aria-label": words.fold, title: words.fold, value: marker.fold ?? '', onChange: (event) => rewrite(marker.type, (event.target.value || null)), children: [_jsx("option", { value: "", children: words.foldNone }), _jsx("option", { value: "+", children: words.foldOpen }), _jsx("option", { value: "-", children: words.foldClosed })] })] }), _jsx(Snippet, { text: text })] }));
|
|
75
75
|
}
|
|
76
76
|
// ── Images ───────────────────────────────────────────────────────────────────
|
|
77
|
-
/** The narrowest an image is
|
|
78
|
-
const
|
|
77
|
+
/** The narrowest an image is sized to, by a field or the handle: something a reader still sees (#105). */
|
|
78
|
+
export const MIN_IMAGE_SIZE = 16;
|
|
79
|
+
/** How long after a pointer or a touch on its controls a click on the image is not taken as one to edit its source (#107). */
|
|
80
|
+
const CONTROLS_GUARD_MS = 800;
|
|
81
|
+
/** How long after a control of an image writes the image is held where it was on the screen (#107). */
|
|
82
|
+
const SCROLL_HOLD_MS = 600;
|
|
83
|
+
/** A move of the image larger than this, in that moment, is the browser's and not the author's. */
|
|
84
|
+
const SCROLL_JUMP = 120;
|
|
79
85
|
/**
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
86
|
+
* Holds the image where it is on the screen while one of its controls writes (#107). The write draws
|
|
87
|
+
* the image again for its new source, which takes the control that had the focus out of the page,
|
|
88
|
+
* and Chrome on a touch screen then scrolls the page a screen or more away, with no script asking
|
|
89
|
+
* it to: the image and its controls were gone from sight (*observed* in the playground, Pixel 7,
|
|
90
|
+
* 2026-10-16). For a moment after the write, a move of the image larger than a jump is undone, by
|
|
91
|
+
* scrolling what the editor scrolls in — a pane of the host, or the window — by as much.
|
|
92
|
+
*/
|
|
93
|
+
function holdInPlace(view, block, run) {
|
|
94
|
+
const top = block.getBoundingClientRect().top;
|
|
95
|
+
let scroller = view.scrollDOM;
|
|
96
|
+
while (scroller && !(scroller.scrollHeight > scroller.clientHeight && /(auto|scroll)/.test(getComputedStyle(scroller).overflowY)))
|
|
97
|
+
scroller = scroller.parentElement;
|
|
98
|
+
const scrolled = () => (scroller ? scroller.scrollTop : window.scrollY);
|
|
99
|
+
const before = scrolled();
|
|
100
|
+
const back = () => {
|
|
101
|
+
// The editor lets go of a block it scrolled out of its viewport: the page goes back to where it was.
|
|
102
|
+
const moved = block.isConnected ? block.getBoundingClientRect().top - top : before - scrolled();
|
|
103
|
+
if (Math.abs(moved) <= SCROLL_JUMP)
|
|
104
|
+
return;
|
|
105
|
+
if (scroller)
|
|
106
|
+
scroller.scrollBy(0, moved);
|
|
107
|
+
else
|
|
108
|
+
window.scrollBy(0, moved);
|
|
109
|
+
};
|
|
110
|
+
run();
|
|
111
|
+
window.addEventListener('scroll', back, true);
|
|
112
|
+
setTimeout(() => window.removeEventListener('scroll', back, true), SCROLL_HOLD_MS);
|
|
113
|
+
}
|
|
114
|
+
/** A number as a field holds it: digits and nothing else, or `null`. */
|
|
115
|
+
function wholeNumber(text) {
|
|
116
|
+
return /^\d{1,6}$/.test(text.trim()) ? Number(text.trim()) : null;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* An image drawn in edit mode with what its notation writes set in place (#98, #106): its width and
|
|
120
|
+
* its height with a lock between them — locked, the height follows and only the width is written,
|
|
121
|
+
* `|300`; open, both are, `|300x180` — the width's unit, pixels or a share of the page (`|50%`,
|
|
122
|
+
* #108), its description for a Markdown image, a button back to its own size, and a handle at its
|
|
123
|
+
* corner that drags it, keeping the proportion or, with the lock open or Shift held, not. Each
|
|
124
|
+
* writes the dimensions or the description of its notation, and no other byte.
|
|
125
|
+
*
|
|
126
|
+
* A field selects what it holds when it takes the focus, so typing replaces it, and writes on Enter
|
|
127
|
+
* or when it is left; Escape leaves the note as it is. A value outside the range a reader can see
|
|
128
|
+
* — from 16 pixels to the column or the image's own width, the wider — is refused where it was
|
|
129
|
+
* typed, never brought into the range and written (#105).
|
|
130
|
+
*
|
|
131
|
+
* The controls stand under the picture, never over it, and nothing that starts on them or in the
|
|
132
|
+
* moment after reaches the click that shows the image's source (#107): they are all marked as
|
|
133
|
+
* controls, gaps included, and the frame is marked busy while one of them has the focus, while the
|
|
134
|
+
* handle is dragged and for a moment after a pointer or a touch on them.
|
|
135
|
+
*
|
|
136
|
+
* The controls appear once an image is drawn inside it, so an embed of a file that is not an image,
|
|
137
|
+
* or a host's own drawing without one, offers none.
|
|
85
138
|
*/
|
|
86
139
|
export function ImageBlock({ view, source, children }) {
|
|
87
140
|
const words = useHost().strings.editor;
|
|
@@ -89,9 +142,14 @@ export function ImageBlock({ view, source, children }) {
|
|
|
89
142
|
const size = readImageSize(source);
|
|
90
143
|
const [image, setImage] = useState(null);
|
|
91
144
|
const [natural, setNatural] = useState(null);
|
|
92
|
-
const [
|
|
145
|
+
const [locked, setLocked] = useState(() => size?.height == null);
|
|
146
|
+
const [unit, setUnit] = useState(() => (size?.percent == null ? 'px' : '%'));
|
|
147
|
+
const [typed, setTyped] = useState({});
|
|
148
|
+
const [refused, setRefused] = useState(null);
|
|
93
149
|
const [dragged, setDragged] = useState(null);
|
|
150
|
+
const [shift, setShift] = useState(false);
|
|
94
151
|
const drag = useRef(null);
|
|
152
|
+
const guard = useRef(null);
|
|
95
153
|
// An embed's image arrives when the host answers; the controls wait for it.
|
|
96
154
|
useEffect(() => {
|
|
97
155
|
const element = frame.current;
|
|
@@ -108,74 +166,288 @@ export function ImageBlock({ view, source, children }) {
|
|
|
108
166
|
useEffect(() => {
|
|
109
167
|
if (!image)
|
|
110
168
|
return;
|
|
111
|
-
const read = () => setNatural(image.naturalWidth
|
|
169
|
+
const read = () => setNatural(image.naturalWidth && image.naturalHeight ? { width: image.naturalWidth, height: image.naturalHeight } : null);
|
|
112
170
|
read();
|
|
113
171
|
image.addEventListener('load', read);
|
|
114
172
|
return () => image.removeEventListener('load', read);
|
|
115
173
|
}, [image]);
|
|
116
|
-
|
|
174
|
+
useEffect(() => () => {
|
|
175
|
+
if (guard.current)
|
|
176
|
+
clearTimeout(guard.current);
|
|
177
|
+
}, []);
|
|
178
|
+
// The handle and the controls are placed by the picture itself, not by the frame, which also holds
|
|
179
|
+
// its margins and, in an embed, a paragraph's: its bottom right corner is where the handle goes.
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
const element = frame.current;
|
|
182
|
+
if (!image || !element)
|
|
183
|
+
return;
|
|
184
|
+
const place = () => {
|
|
185
|
+
const outer = element.getBoundingClientRect();
|
|
186
|
+
const inner = image.getBoundingClientRect();
|
|
187
|
+
element.style.setProperty('--edit-image-bottom', `${Math.round(inner.bottom - outer.top)}px`);
|
|
188
|
+
element.style.setProperty('--edit-image-right', `${Math.round(inner.right - outer.left)}px`);
|
|
189
|
+
};
|
|
190
|
+
place();
|
|
191
|
+
image.addEventListener('load', place);
|
|
192
|
+
if (typeof ResizeObserver === 'undefined')
|
|
193
|
+
return () => image.removeEventListener('load', place);
|
|
194
|
+
const observer = new ResizeObserver(place);
|
|
195
|
+
observer.observe(image);
|
|
196
|
+
observer.observe(element);
|
|
197
|
+
return () => {
|
|
198
|
+
image.removeEventListener('load', place);
|
|
199
|
+
observer.disconnect();
|
|
200
|
+
};
|
|
201
|
+
}, [image]);
|
|
202
|
+
/** Marks the frame busy for a moment, so the click a touch or a pointer leaves behind reveals nothing (#107). */
|
|
203
|
+
const hold = () => {
|
|
204
|
+
const element = frame.current;
|
|
205
|
+
if (!element)
|
|
206
|
+
return;
|
|
207
|
+
element.dataset['msBusy'] = '';
|
|
208
|
+
if (guard.current)
|
|
209
|
+
clearTimeout(guard.current);
|
|
210
|
+
guard.current = setTimeout(() => {
|
|
211
|
+
if (!element.matches(':focus-within') && !drag.current)
|
|
212
|
+
delete element.dataset['msBusy'];
|
|
213
|
+
}, CONTROLS_GUARD_MS);
|
|
214
|
+
};
|
|
215
|
+
/** The width the note is read at, where the image stands: what a share of the page is a share of. */
|
|
216
|
+
const column = () => {
|
|
217
|
+
const content = frame.current?.closest('.cm-content') ?? null;
|
|
218
|
+
const width = content ? content.clientWidth - parseFloat(getComputedStyle(content).paddingLeft || '0') - parseFloat(getComputedStyle(content).paddingRight || '0') : 0;
|
|
219
|
+
return width > 0 ? Math.round(width) : null;
|
|
220
|
+
};
|
|
221
|
+
/** The image as it is drawn now, or `null` before it is laid out. */
|
|
222
|
+
const drawn = () => {
|
|
223
|
+
const box = image?.getBoundingClientRect();
|
|
224
|
+
return box && box.width > 0 && box.height > 0 ? { width: Math.round(box.width), height: Math.round(box.height) } : null;
|
|
225
|
+
};
|
|
226
|
+
/** Its proportion, height over width: its own, else as it is drawn. */
|
|
227
|
+
const ratio = () => {
|
|
228
|
+
const shape = natural ?? drawn();
|
|
229
|
+
return shape ? shape.height / shape.width : null;
|
|
230
|
+
};
|
|
231
|
+
/** What a field accepts (#105): a share from 1% to 100%, or pixels a reader sees, up to the column or the image's own size. */
|
|
232
|
+
const rangeOf = (field) => {
|
|
233
|
+
if (field === 'width' && unit === '%')
|
|
234
|
+
return { min: 1, max: 100 };
|
|
235
|
+
const widest = Math.max(column() ?? 0, natural?.width ?? 0);
|
|
236
|
+
const width = widest > 0 ? widest : MAX_IMAGE_WIDTH;
|
|
237
|
+
if (field === 'width')
|
|
238
|
+
return { min: MIN_IMAGE_SIZE, max: width };
|
|
239
|
+
const proportion = ratio();
|
|
240
|
+
const tallest = Math.max(proportion === null ? 0 : Math.ceil(width * proportion), natural?.height ?? 0);
|
|
241
|
+
return { min: MIN_IMAGE_SIZE, max: tallest > 0 && widest > 0 ? tallest : MAX_IMAGE_WIDTH };
|
|
242
|
+
};
|
|
243
|
+
const apply = (changes) => {
|
|
117
244
|
const widget = frame.current?.closest('.cm-ms-inline-block, .cm-ms-block');
|
|
118
|
-
if (!widget)
|
|
245
|
+
if (!widget || changes.length === 0)
|
|
119
246
|
return;
|
|
120
247
|
const from = view.posAtDOM(widget);
|
|
121
248
|
// The widget stands where its source was: anything else means the note moved under it.
|
|
122
249
|
if (view.state.sliceDoc(from, from + source.length) !== source)
|
|
123
250
|
return;
|
|
124
|
-
write(view,
|
|
251
|
+
holdInPlace(view, widget, () => write(view, changes.map((change) => ({ ...change, from: change.from + from, to: change.to + from }))));
|
|
125
252
|
};
|
|
126
|
-
const
|
|
127
|
-
|
|
253
|
+
const resize = (dimensions) => apply(resizeImage(source, 0, dimensions));
|
|
254
|
+
/** The width it is written or drawn at, in pixels, for a height written beside it. */
|
|
255
|
+
const widthNow = () => size?.width ?? drawn()?.width ?? natural?.width ?? null;
|
|
256
|
+
/** Writes what a field holds, or refuses it in place; `true` when the field may let go of what was typed. */
|
|
257
|
+
const commit = (field) => {
|
|
258
|
+
const text = typed[field];
|
|
259
|
+
if (text === undefined)
|
|
260
|
+
return true;
|
|
261
|
+
if (text.trim() === '') {
|
|
262
|
+
// Empty is "not set": no width is its own size, no height a width alone (#106).
|
|
263
|
+
setTyped((now) => ({ ...now, [field]: undefined }));
|
|
264
|
+
setRefused(null);
|
|
265
|
+
if (field === 'width')
|
|
266
|
+
resize(null);
|
|
267
|
+
else if (size?.width != null)
|
|
268
|
+
resize({ width: size.width, height: null, percent: null });
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
const value = wholeNumber(text);
|
|
272
|
+
const range = rangeOf(field);
|
|
273
|
+
if (value === null || value < range.min || value > range.max) {
|
|
274
|
+
setRefused({ field, range });
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
setRefused(null);
|
|
278
|
+
setTyped((now) => ({ ...now, [field]: undefined }));
|
|
279
|
+
if (field === 'width') {
|
|
280
|
+
if (unit === '%')
|
|
281
|
+
resize({ width: null, height: null, percent: value });
|
|
282
|
+
else if (locked)
|
|
283
|
+
resize({ width: value, height: null, percent: null });
|
|
284
|
+
else
|
|
285
|
+
resize({ width: value, height: size?.height ?? drawn()?.height ?? null, percent: null });
|
|
286
|
+
}
|
|
287
|
+
else if (locked) {
|
|
288
|
+
// A height typed under the lock sets the width that draws it, and only the width is written.
|
|
289
|
+
const proportion = ratio();
|
|
290
|
+
if (proportion !== null)
|
|
291
|
+
resize({ width: Math.max(1, Math.round(value / proportion)), height: null, percent: null });
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
const width = widthNow();
|
|
295
|
+
if (width !== null)
|
|
296
|
+
resize({ width, height: value, percent: null });
|
|
297
|
+
}
|
|
298
|
+
return true;
|
|
299
|
+
};
|
|
300
|
+
const commitDescription = () => {
|
|
301
|
+
const text = typed.description;
|
|
302
|
+
if (text === undefined)
|
|
128
303
|
return;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if (typed.trim() === '')
|
|
132
|
-
resize(null);
|
|
133
|
-
else if (Number.isFinite(width) && width >= 1)
|
|
134
|
-
resize(width);
|
|
304
|
+
setTyped((now) => ({ ...now, description: undefined }));
|
|
305
|
+
apply(describeImage(source, 0, text));
|
|
135
306
|
};
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
event.
|
|
139
|
-
|
|
307
|
+
const field = (name) => ({
|
|
308
|
+
onChange: (event) => {
|
|
309
|
+
const value = event.target.value;
|
|
310
|
+
setTyped((now) => ({ ...now, [name]: value }));
|
|
311
|
+
if (refused?.field === name)
|
|
312
|
+
setRefused(null);
|
|
313
|
+
},
|
|
314
|
+
// Whatever takes it there — a click, a double click, Tab — the value is selected, so what is typed replaces it (#105).
|
|
315
|
+
onFocus: (event) => event.currentTarget.select(),
|
|
316
|
+
onMouseUp: (event) => {
|
|
317
|
+
const input = event.currentTarget;
|
|
318
|
+
if (document.activeElement === input && input.selectionStart === input.selectionEnd && typed[name] === undefined) {
|
|
319
|
+
event.preventDefault();
|
|
320
|
+
input.select();
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
onDoubleClick: (event) => event.currentTarget.select(),
|
|
324
|
+
onKeyDown: (event) => {
|
|
325
|
+
if (event.key === 'Enter') {
|
|
326
|
+
event.preventDefault();
|
|
327
|
+
if (name === 'description')
|
|
328
|
+
commitDescription();
|
|
329
|
+
else if (commit(name))
|
|
330
|
+
event.currentTarget.select();
|
|
331
|
+
}
|
|
332
|
+
else if (event.key === 'Escape') {
|
|
333
|
+
event.preventDefault();
|
|
334
|
+
setTyped((now) => ({ ...now, [name]: undefined }));
|
|
335
|
+
setRefused(null);
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
onBlur: () => {
|
|
339
|
+
if (name === 'description')
|
|
340
|
+
commitDescription();
|
|
341
|
+
// A value refused is let go when the field is left, and the note keeps what it held.
|
|
342
|
+
else if (!commit(name)) {
|
|
343
|
+
setTyped((now) => ({ ...now, [name]: undefined }));
|
|
344
|
+
setRefused(null);
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
const toggleLock = () => {
|
|
349
|
+
const next = !locked;
|
|
350
|
+
setLocked(next);
|
|
351
|
+
// Locked, the height follows the width: one written beside it goes (#106).
|
|
352
|
+
if (next && size?.height != null && size.width !== null)
|
|
353
|
+
resize({ width: size.width, height: null, percent: null });
|
|
354
|
+
};
|
|
355
|
+
const chooseUnit = (next) => {
|
|
356
|
+
setUnit(next);
|
|
357
|
+
setTyped((now) => ({ ...now, width: undefined }));
|
|
358
|
+
setRefused(null);
|
|
359
|
+
const now = drawn();
|
|
360
|
+
const wide = column();
|
|
361
|
+
// The size it is drawn at, said in the other unit, when it has one.
|
|
362
|
+
if (next === '%' && size?.percent == null && size?.width != null && now && wide) {
|
|
363
|
+
resize({ width: null, height: null, percent: Math.min(100, Math.max(1, Math.round((now.width / wide) * 100))) });
|
|
140
364
|
}
|
|
141
|
-
else if (
|
|
142
|
-
|
|
143
|
-
setTyped(null);
|
|
365
|
+
else if (next === 'px' && size?.percent != null && now) {
|
|
366
|
+
resize({ width: now.width, height: null, percent: null });
|
|
144
367
|
}
|
|
368
|
+
if (next === '%')
|
|
369
|
+
setLocked(true);
|
|
370
|
+
};
|
|
371
|
+
const free = (!locked && unit === 'px') || shift;
|
|
372
|
+
const shownWidth = dragged ? (unit === '%' ? percentOf(dragged.width) : dragged.width) : unit === '%' ? size?.percent ?? null : size?.width ?? null;
|
|
373
|
+
const shownHeight = dragged ? dragged.height : size?.height ?? (locked && size?.width != null && ratio() !== null ? Math.round(size.width * ratio()) : null);
|
|
374
|
+
function percentOf(width) {
|
|
375
|
+
const wide = column();
|
|
376
|
+
return wide ? Math.min(100, Math.max(1, Math.round((width / wide) * 100))) : null;
|
|
377
|
+
}
|
|
378
|
+
const endDrag = (event, cancelled) => {
|
|
379
|
+
const start = drag.current;
|
|
380
|
+
if (!start || start.pointer !== event.pointerId)
|
|
381
|
+
return;
|
|
382
|
+
drag.current = null;
|
|
383
|
+
const end = drawn();
|
|
384
|
+
setDragged(null);
|
|
385
|
+
if (image) {
|
|
386
|
+
image.style.width = '';
|
|
387
|
+
image.style.height = '';
|
|
388
|
+
image.style.maxWidth = '';
|
|
389
|
+
}
|
|
390
|
+
hold();
|
|
391
|
+
if (cancelled || !end)
|
|
392
|
+
return;
|
|
393
|
+
if (Math.abs(end.width - start.width) < 1 && Math.abs(end.height - start.height) < 1)
|
|
394
|
+
return;
|
|
395
|
+
if (unit === '%') {
|
|
396
|
+
const percent = percentOf(end.width);
|
|
397
|
+
if (percent !== null)
|
|
398
|
+
resize({ width: null, height: null, percent });
|
|
399
|
+
}
|
|
400
|
+
else if (free || event.shiftKey)
|
|
401
|
+
resize({ width: end.width, height: end.height, percent: null });
|
|
402
|
+
else
|
|
403
|
+
resize({ width: end.width, height: null, percent: null });
|
|
145
404
|
};
|
|
146
|
-
const shown = dragged ?? size?.width ?? null;
|
|
147
405
|
const offered = size !== null && image !== null;
|
|
148
|
-
|
|
406
|
+
const unitWord = unit === '%' ? '%' : 'px';
|
|
407
|
+
const invalid = (name) => (refused?.field === name ? { 'aria-invalid': true, title: words.imageOutOfRange(refused.range.min, refused.range.max, name === 'width' ? unitWord : 'px') } : {});
|
|
408
|
+
return (_jsxs("span", { ref: frame, className: "edit-image", "data-resizing": dragged === null ? undefined : '', onFocus: () => frame.current && (frame.current.dataset['msBusy'] = ''), onBlur: hold, onMouseDown: (event) => {
|
|
409
|
+
// The frame's own area is the space between the picture and its controls: a press there is
|
|
410
|
+
// one meant for them, and shows nothing (#107).
|
|
411
|
+
if (event.target === event.currentTarget) {
|
|
412
|
+
event.preventDefault();
|
|
413
|
+
hold();
|
|
414
|
+
}
|
|
415
|
+
}, children: [children, offered && (_jsxs(_Fragment, { children: [_jsxs("span", { className: "edit-image-controls", "data-ms-control": "", onPointerDown: hold, onTouchStart: hold, onMouseDown: (event) => {
|
|
416
|
+
// A press anywhere but on a field, a choice or a button — a gap between them, the
|
|
417
|
+
// padding, a word beside a field — keeps the focus where it is. Its default would put
|
|
418
|
+
// the editor's caret where the press fell, and the image would show its source (#107).
|
|
419
|
+
if (!event.target.closest('input, select, textarea, button'))
|
|
420
|
+
event.preventDefault();
|
|
421
|
+
}, children: [size.description ? (_jsx("input", { className: "edit-field edit-image-description", type: "text", "aria-label": words.imageDescription, title: words.imageDescription, placeholder: image.alt, value: typed.description ?? size.description.text, ...field('description') })) : (
|
|
422
|
+
// An embed writes no description (§5.8): the file's own is shown, and is not edited here.
|
|
423
|
+
_jsx("input", { className: "edit-field edit-image-description", type: "text", readOnly: true, "aria-label": words.imageDescription, title: words.imageDescription, placeholder: image.alt, value: "" })), _jsxs("span", { className: "edit-image-size", children: [_jsx("input", { className: "edit-field edit-image-width", type: "text", inputMode: "numeric", autoComplete: "off", "aria-label": words.imageWidth, title: words.imageWidth, placeholder: unit === '%' ? '' : natural === null ? '' : String(natural.width), value: typed.width ?? (shownWidth === null ? '' : String(shownWidth)), ...field('width'), ...invalid('width') }), _jsxs("select", { className: "edit-image-unit", "aria-label": words.imageUnit, title: words.imageUnit, value: unit, onChange: (event) => chooseUnit(event.target.value), children: [_jsx("option", { value: "px", children: "px" }), _jsx("option", { value: "%", children: "%" })] }), _jsx(Control, { label: locked ? words.imageKeepRatio : words.imageFreeRatio, onClick: toggleLock, pressed: locked, disabled: unit === '%', focusable: true, children: locked ? ICON_RATIO_LOCKED : ICON_RATIO_FREE }), _jsx("input", { className: "edit-field edit-image-height", type: "text", inputMode: "numeric", autoComplete: "off", "aria-label": words.imageHeight, title: words.imageHeight, disabled: unit === '%', placeholder: natural === null || unit === '%' ? '' : String(natural.height), value: unit === '%' ? '' : (typed.height ?? (shownHeight === null ? '' : String(shownHeight))), ...field('height'), ...invalid('height') }), _jsx("span", { className: "edit-image-unit-label", "aria-hidden": "true", children: "px" }), _jsx(Control, { label: words.imageOwnSize, onClick: () => resize(null), pressed: size.width === null && size.percent === null, focusable: true, children: ICON_OWN_SIZE })] }), refused && (_jsx("span", { className: "edit-image-refused", role: "status", children: words.imageOutOfRange(refused.range.min, refused.range.max, refused.field === 'width' ? unitWord : 'px') }))] }), _jsx("span", { className: "edit-image-handle", "data-ms-control": "", "data-free": free ? '' : undefined, title: free ? words.imageResizeFree : words.imageResizeKeep, "aria-hidden": "true", onPointerDown: (event) => {
|
|
149
424
|
event.preventDefault();
|
|
425
|
+
event.stopPropagation();
|
|
426
|
+
const now = drawn();
|
|
427
|
+
if (!now)
|
|
428
|
+
return;
|
|
150
429
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
151
|
-
drag.current = { x: event.clientX,
|
|
152
|
-
|
|
430
|
+
drag.current = { x: event.clientX, y: event.clientY, ...now, pointer: event.pointerId };
|
|
431
|
+
if (frame.current)
|
|
432
|
+
frame.current.dataset['msBusy'] = '';
|
|
433
|
+
setShift(event.shiftKey);
|
|
434
|
+
setDragged(now);
|
|
153
435
|
}, onPointerMove: (event) => {
|
|
154
436
|
const start = drag.current;
|
|
155
|
-
if (!start || start.pointer !== event.pointerId)
|
|
437
|
+
if (!image || !start || start.pointer !== event.pointerId)
|
|
156
438
|
return;
|
|
157
|
-
|
|
439
|
+
setShift(event.shiftKey);
|
|
440
|
+
const width = Math.max(MIN_IMAGE_SIZE, Math.round(start.width + event.clientX - start.x));
|
|
441
|
+
image.style.maxWidth = '100%';
|
|
158
442
|
image.style.width = `${width}px`;
|
|
159
|
-
|
|
443
|
+
if ((!locked && unit === 'px') || event.shiftKey) {
|
|
444
|
+
image.style.height = `${Math.max(MIN_IMAGE_SIZE, Math.round(start.height + event.clientY - start.y))}px`;
|
|
445
|
+
}
|
|
446
|
+
else
|
|
447
|
+
image.style.height = 'auto';
|
|
160
448
|
// What the page lets it be: never wider than the column.
|
|
161
|
-
setDragged(
|
|
162
|
-
}, onPointerUp: (event) =>
|
|
163
|
-
const start = drag.current;
|
|
164
|
-
if (!start || start.pointer !== event.pointerId)
|
|
165
|
-
return;
|
|
166
|
-
drag.current = null;
|
|
167
|
-
const width = Math.round(image.getBoundingClientRect().width);
|
|
168
|
-
setDragged(null);
|
|
169
|
-
image.style.width = '';
|
|
170
|
-
image.style.height = '';
|
|
171
|
-
if (Math.abs(width - start.width) >= 1)
|
|
172
|
-
resize(width);
|
|
173
|
-
}, onPointerCancel: () => {
|
|
174
|
-
drag.current = null;
|
|
175
|
-
setDragged(null);
|
|
176
|
-
image.style.width = '';
|
|
177
|
-
image.style.height = '';
|
|
178
|
-
} })] }))] }));
|
|
449
|
+
setDragged(drawn());
|
|
450
|
+
}, onPointerUp: (event) => endDrag(event, false), onPointerCancel: (event) => endDrag(event, true), children: ICON_RESIZE })] }))] }));
|
|
179
451
|
}
|
|
180
452
|
// ── Tables ───────────────────────────────────────────────────────────────────
|
|
181
453
|
/** Where the focus goes back to when a table is drawn again after an edit of one of its cells. */
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where in a block's source a click on its drawing lands (#102): the text under the pointer is
|
|
3
|
+
* found in the source the block was drawn from, so the caret goes where the author clicked once
|
|
4
|
+
* the source is revealed, rather than to the end of the block's first line.
|
|
5
|
+
*/
|
|
6
|
+
/** The text node and offset under a point of the page, where the browser can say. */
|
|
7
|
+
export declare function caretAt(x: number, y: number): {
|
|
8
|
+
node: Node;
|
|
9
|
+
offset: number;
|
|
10
|
+
} | null;
|
|
11
|
+
/**
|
|
12
|
+
* The offset in `source` that draws `offset` in the text node `node` of the drawing `root`, or
|
|
13
|
+
* `null` when the text there is not written in the source as it is drawn — a formula's typesetting,
|
|
14
|
+
* an escaped character, a note an embed draws — and the click cannot be mapped.
|
|
15
|
+
*/
|
|
16
|
+
export declare function sourceOffsetOf(root: HTMLElement, source: string, node: Node, offset: number): number | null;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where in a block's source a click on its drawing lands (#102): the text under the pointer is
|
|
3
|
+
* found in the source the block was drawn from, so the caret goes where the author clicked once
|
|
4
|
+
* the source is revealed, rather than to the end of the block's first line.
|
|
5
|
+
*/
|
|
6
|
+
/** How much of the text beside the pointer is looked for when its whole line is not in the source. */
|
|
7
|
+
const WINDOW = 24;
|
|
8
|
+
/** The least of it looked for: a letter or two is found anywhere. */
|
|
9
|
+
const LEAST = 3;
|
|
10
|
+
/** The text node and offset under a point of the page, where the browser can say. */
|
|
11
|
+
export function caretAt(x, y) {
|
|
12
|
+
const position = document.caretPositionFromPoint?.(x, y);
|
|
13
|
+
if (position)
|
|
14
|
+
return { node: position.offsetNode, offset: position.offset };
|
|
15
|
+
const range = document.caretRangeFromPoint?.(x, y);
|
|
16
|
+
return range ? { node: range.startContainer, offset: range.startOffset } : null;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The offset in `source` that draws `offset` in the text node `node` of the drawing `root`, or
|
|
20
|
+
* `null` when the text there is not written in the source as it is drawn — a formula's typesetting,
|
|
21
|
+
* an escaped character, a note an embed draws — and the click cannot be mapped.
|
|
22
|
+
*/
|
|
23
|
+
export function sourceOffsetOf(root, source, node, offset) {
|
|
24
|
+
if (node.nodeType !== Node.TEXT_NODE || !root.contains(node))
|
|
25
|
+
return null;
|
|
26
|
+
const data = node.textContent ?? '';
|
|
27
|
+
const at = Math.max(0, Math.min(offset, data.length));
|
|
28
|
+
// A drawn paragraph joins its lines, which the source writes each after its own marker (`> `).
|
|
29
|
+
const lineStart = data.lastIndexOf('\n', at - 1) + 1;
|
|
30
|
+
const lineEnd = data.indexOf('\n', at) === -1 ? data.length : data.indexOf('\n', at);
|
|
31
|
+
const before = textBefore(root, node) + data.slice(0, lineStart);
|
|
32
|
+
const left = data.slice(Math.max(lineStart, at - WINDOW), at);
|
|
33
|
+
const right = data.slice(at, Math.min(lineEnd, at + WINDOW));
|
|
34
|
+
const candidates = [
|
|
35
|
+
{ text: data.slice(lineStart, lineEnd), caret: at - lineStart },
|
|
36
|
+
{ text: left + right, caret: left.length },
|
|
37
|
+
{ text: left, caret: left.length },
|
|
38
|
+
{ text: right, caret: 0 },
|
|
39
|
+
];
|
|
40
|
+
for (const [index, { text, caret }] of candidates.entries()) {
|
|
41
|
+
if (!text.trim() || (index > 0 && text.trim().length < LEAST))
|
|
42
|
+
continue;
|
|
43
|
+
const found = nth(source, text, count(before, text));
|
|
44
|
+
if (found !== -1)
|
|
45
|
+
return found + caret;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
/** The text drawn before `node` within `root`. */
|
|
50
|
+
function textBefore(root, node) {
|
|
51
|
+
const range = document.createRange();
|
|
52
|
+
range.setStart(root, 0);
|
|
53
|
+
range.setEnd(node, 0);
|
|
54
|
+
return range.toString();
|
|
55
|
+
}
|
|
56
|
+
function count(text, piece) {
|
|
57
|
+
let found = 0;
|
|
58
|
+
for (let at = text.indexOf(piece); at !== -1; at = text.indexOf(piece, at + 1))
|
|
59
|
+
found++;
|
|
60
|
+
return found;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Where the `n`-th occurrence of `piece` (from 0) starts in `text`: the same text drawn twice is
|
|
64
|
+
* mapped to the occurrence it draws, and to the last one when the drawing shows more than the source.
|
|
65
|
+
*/
|
|
66
|
+
function nth(text, piece, n) {
|
|
67
|
+
let last = -1;
|
|
68
|
+
for (let at = text.indexOf(piece), seen = 0; at !== -1; at = text.indexOf(piece, at + 1), seen++) {
|
|
69
|
+
if (seen === n)
|
|
70
|
+
return at;
|
|
71
|
+
last = at;
|
|
72
|
+
}
|
|
73
|
+
return last;
|
|
74
|
+
}
|
package/dist/edit/heights.js
CHANGED
|
@@ -138,6 +138,8 @@ export function estimateHeight(kind, source, metrics) {
|
|
|
138
138
|
const size = readImageSize(source.trim());
|
|
139
139
|
if (size?.height != null)
|
|
140
140
|
return size.height;
|
|
141
|
+
if (size?.percent != null)
|
|
142
|
+
return Math.round(((metrics.width * size.percent) / 100) * 0.75);
|
|
141
143
|
return size?.width != null ? Math.round(size.width * 0.75) : UNDECLARED_IMAGE_HEIGHT;
|
|
142
144
|
}
|
|
143
145
|
}
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The size an image is drawn at, as its notation writes it (#98): the dimensions after the pipe
|
|
3
3
|
* of a Markdown image's description, `` (`SPEC.md` §3.14), or of the
|
|
4
|
-
* embed of a file the notebook keeps, `![[photo.jpg|300]]` (§5.7)
|
|
5
|
-
* rewrites them, and
|
|
4
|
+
* embed of a file the notebook keeps, `![[photo.jpg|300]]` (§5.7) — pixels, a width and a height,
|
|
5
|
+
* or a share of the page (#108). The editor reads them and rewrites them, and the description
|
|
6
|
+
* before the pipe of a Markdown image (#106), and no other byte.
|
|
6
7
|
*/
|
|
8
|
+
import { type Dimensions } from '../image-dimensions.js';
|
|
7
9
|
import type { TextChange } from './table.js';
|
|
8
|
-
export interface ImageSize {
|
|
9
|
-
/** The width it declares, or `null` when it is drawn at its own size. */
|
|
10
|
-
readonly width: number | null;
|
|
11
|
-
readonly height: number | null;
|
|
10
|
+
export interface ImageSize extends Dimensions {
|
|
12
11
|
/**
|
|
13
12
|
* Where in the source the dimensions are written, their pipe included; an empty span where they
|
|
14
13
|
* would be written when there are none.
|
|
@@ -19,6 +18,15 @@ export interface ImageSize {
|
|
|
19
18
|
};
|
|
20
19
|
/** What separates them from the rest: `|`, or `\|` in an embed whose pipe is escaped. */
|
|
21
20
|
readonly pipe: string;
|
|
21
|
+
/**
|
|
22
|
+
* The description of a Markdown image, as it reads, and where it is written, before its pipe;
|
|
23
|
+
* `null` for an embed, whose pipe is the only thing it writes (§5.8).
|
|
24
|
+
*/
|
|
25
|
+
readonly description: {
|
|
26
|
+
readonly text: string;
|
|
27
|
+
readonly from: number;
|
|
28
|
+
readonly to: number;
|
|
29
|
+
} | null;
|
|
22
30
|
}
|
|
23
31
|
/** The largest width the notation writes: five digits (`SPEC.md` §3.14). */
|
|
24
32
|
export declare const MAX_IMAGE_WIDTH = 99999;
|
|
@@ -29,8 +37,16 @@ export declare const MAX_IMAGE_WIDTH = 99999;
|
|
|
29
37
|
*/
|
|
30
38
|
export declare function readImageSize(source: string): ImageSize | null;
|
|
31
39
|
/**
|
|
32
|
-
* The change that draws the image whose source starts at `from` at
|
|
33
|
-
* or at its own size for `null`: the
|
|
34
|
-
* Empty when the source is not an
|
|
40
|
+
* The change that draws the image whose source starts at `from` at the dimensions asked — a width
|
|
41
|
+
* as a number, its height following, or whole `Dimensions` — or at its own size for `null`: the
|
|
42
|
+
* dimensions written, replaced or taken away, and nothing else. Empty when the source is not an
|
|
43
|
+
* image's notation, already says so, or the dimensions asked are none the notation writes: a
|
|
44
|
+
* value out of its range is refused, never brought into it.
|
|
45
|
+
*/
|
|
46
|
+
export declare function resizeImage(source: string, from: number, asked: number | Dimensions | null): TextChange[];
|
|
47
|
+
/**
|
|
48
|
+
* The change that writes `text` as the description of the Markdown image whose source starts at
|
|
49
|
+
* `from`, before its pipe, which it keeps (§3.14): a bracket and a backslash escaped, so the image
|
|
50
|
+
* stays one. Empty for an embed, which has no description to write (§5.8), and when nothing changes.
|
|
35
51
|
*/
|
|
36
|
-
export declare function
|
|
52
|
+
export declare function describeImage(source: string, from: number, text: string): TextChange[];
|