@memorysmithapp/markdown-renderer 0.7.1 → 0.8.1
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 +3 -2
- package/dist/edit/blocks.d.ts +24 -5
- package/dist/edit/blocks.js +384 -56
- 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 +112 -26
- 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 +16 -1
- package/dist/strings.js +9 -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,76 @@ 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
79
|
/**
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
* The size in pixels an image had when its unit was switched to a share of the page, where its
|
|
81
|
+
* source starts: switched back with nothing changed between, it is written again, not the width the
|
|
82
|
+
* share rounds to (#113). Kept by the editor, since the image is drawn anew for its new source.
|
|
83
|
+
*/
|
|
84
|
+
const switchedUnits = new WeakMap();
|
|
85
|
+
/** 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). */
|
|
86
|
+
const CONTROLS_GUARD_MS = 800;
|
|
87
|
+
/** How long after a control of an image writes the image is held where it was on the screen (#107). */
|
|
88
|
+
const SCROLL_HOLD_MS = 600;
|
|
89
|
+
/** A move of the image larger than this, in that moment, is the browser's and not the author's. */
|
|
90
|
+
const SCROLL_JUMP = 120;
|
|
91
|
+
/**
|
|
92
|
+
* Holds the image where it is on the screen while one of its controls writes (#107). The write draws
|
|
93
|
+
* the image again for its new source, which takes the control that had the focus out of the page,
|
|
94
|
+
* and Chrome on a touch screen then scrolls the page a screen or more away, with no script asking
|
|
95
|
+
* it to: the image and its controls were gone from sight (*observed* in the playground, Pixel 7,
|
|
96
|
+
* 2026-10-16). For a moment after the write, a move of the image larger than a jump is undone, by
|
|
97
|
+
* scrolling what the editor scrolls in — a pane of the host, or the window — by as much.
|
|
98
|
+
*/
|
|
99
|
+
function holdInPlace(view, block, run) {
|
|
100
|
+
const top = block.getBoundingClientRect().top;
|
|
101
|
+
let scroller = view.scrollDOM;
|
|
102
|
+
while (scroller && !(scroller.scrollHeight > scroller.clientHeight && /(auto|scroll)/.test(getComputedStyle(scroller).overflowY)))
|
|
103
|
+
scroller = scroller.parentElement;
|
|
104
|
+
const scrolled = () => (scroller ? scroller.scrollTop : window.scrollY);
|
|
105
|
+
const before = scrolled();
|
|
106
|
+
const back = () => {
|
|
107
|
+
// The editor lets go of a block it scrolled out of its viewport: the page goes back to where it was.
|
|
108
|
+
const moved = block.isConnected ? block.getBoundingClientRect().top - top : before - scrolled();
|
|
109
|
+
if (Math.abs(moved) <= SCROLL_JUMP)
|
|
110
|
+
return;
|
|
111
|
+
if (scroller)
|
|
112
|
+
scroller.scrollBy(0, moved);
|
|
113
|
+
else
|
|
114
|
+
window.scrollBy(0, moved);
|
|
115
|
+
};
|
|
116
|
+
run();
|
|
117
|
+
window.addEventListener('scroll', back, true);
|
|
118
|
+
setTimeout(() => window.removeEventListener('scroll', back, true), SCROLL_HOLD_MS);
|
|
119
|
+
}
|
|
120
|
+
/** A number as a field holds it: digits and nothing else, or `null`. */
|
|
121
|
+
function wholeNumber(text) {
|
|
122
|
+
return /^\d{1,6}$/.test(text.trim()) ? Number(text.trim()) : null;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* An image drawn in edit mode with what its notation writes set in place (#98, #106): its width and
|
|
126
|
+
* its height with a lock between them — locked, the height follows and only the width is written,
|
|
127
|
+
* `|300`; open, both are, `|300x180` — the width's unit, pixels or a share of the page (`|50%`,
|
|
128
|
+
* #108), its description for a Markdown image, a button back to its own size, and a handle at its
|
|
129
|
+
* corner that drags it, keeping the proportion or, with the lock open or Shift held, not. Each
|
|
130
|
+
* writes the dimensions or the description of its notation, and no other byte.
|
|
131
|
+
*
|
|
132
|
+
* A field selects what it holds when it takes the focus, so typing replaces it, and writes on Enter
|
|
133
|
+
* or when it is left; Escape leaves the note as it is. A value outside the range a reader can see
|
|
134
|
+
* — from 16 pixels to the column or the image's own width, the wider — is refused where it was
|
|
135
|
+
* typed, never brought into the range and written (#105).
|
|
136
|
+
*
|
|
137
|
+
* The controls stand under the picture, never over it, and nothing that starts on them or in the
|
|
138
|
+
* moment after reaches the click that shows the image's source (#107): they are all marked as
|
|
139
|
+
* controls, gaps included, and the frame is marked busy while one of them has the focus, while the
|
|
140
|
+
* handle is dragged and for a moment after a pointer or a touch on them.
|
|
141
|
+
*
|
|
142
|
+
* Sized in place, as a table is edited in place, the image shows its source only from a button among
|
|
143
|
+
* its controls or when the caret reaches it from the keyboard; a click on the picture shows nothing (#112).
|
|
144
|
+
*
|
|
145
|
+
* The controls appear once an image is drawn inside it, so an embed of a file that is not an image,
|
|
146
|
+
* or a host's own drawing without one, offers none.
|
|
85
147
|
*/
|
|
86
148
|
export function ImageBlock({ view, source, children }) {
|
|
87
149
|
const words = useHost().strings.editor;
|
|
@@ -89,9 +151,14 @@ export function ImageBlock({ view, source, children }) {
|
|
|
89
151
|
const size = readImageSize(source);
|
|
90
152
|
const [image, setImage] = useState(null);
|
|
91
153
|
const [natural, setNatural] = useState(null);
|
|
92
|
-
const [
|
|
154
|
+
const [locked, setLocked] = useState(() => size?.height == null);
|
|
155
|
+
const [unit, setUnit] = useState(() => (size?.percent == null ? 'px' : '%'));
|
|
156
|
+
const [typed, setTyped] = useState({});
|
|
157
|
+
const [refused, setRefused] = useState(null);
|
|
93
158
|
const [dragged, setDragged] = useState(null);
|
|
159
|
+
const [shift, setShift] = useState(false);
|
|
94
160
|
const drag = useRef(null);
|
|
161
|
+
const guard = useRef(null);
|
|
95
162
|
// An embed's image arrives when the host answers; the controls wait for it.
|
|
96
163
|
useEffect(() => {
|
|
97
164
|
const element = frame.current;
|
|
@@ -108,74 +175,335 @@ export function ImageBlock({ view, source, children }) {
|
|
|
108
175
|
useEffect(() => {
|
|
109
176
|
if (!image)
|
|
110
177
|
return;
|
|
111
|
-
const read = () => setNatural(image.naturalWidth
|
|
178
|
+
const read = () => setNatural(image.naturalWidth && image.naturalHeight ? { width: image.naturalWidth, height: image.naturalHeight } : null);
|
|
112
179
|
read();
|
|
113
180
|
image.addEventListener('load', read);
|
|
114
181
|
return () => image.removeEventListener('load', read);
|
|
115
182
|
}, [image]);
|
|
116
|
-
|
|
183
|
+
useEffect(() => () => {
|
|
184
|
+
if (guard.current)
|
|
185
|
+
clearTimeout(guard.current);
|
|
186
|
+
}, []);
|
|
187
|
+
// The handle and the controls are placed by the picture itself, not by the frame, which also holds
|
|
188
|
+
// its margins and, in an embed, a paragraph's: its bottom right corner is where the handle goes.
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
const element = frame.current;
|
|
191
|
+
if (!image || !element)
|
|
192
|
+
return;
|
|
193
|
+
const place = () => {
|
|
194
|
+
const outer = element.getBoundingClientRect();
|
|
195
|
+
const inner = image.getBoundingClientRect();
|
|
196
|
+
element.style.setProperty('--edit-image-bottom', `${Math.round(inner.bottom - outer.top)}px`);
|
|
197
|
+
element.style.setProperty('--edit-image-right', `${Math.round(inner.right - outer.left)}px`);
|
|
198
|
+
};
|
|
199
|
+
place();
|
|
200
|
+
image.addEventListener('load', place);
|
|
201
|
+
if (typeof ResizeObserver === 'undefined')
|
|
202
|
+
return () => image.removeEventListener('load', place);
|
|
203
|
+
const observer = new ResizeObserver(place);
|
|
204
|
+
observer.observe(image);
|
|
205
|
+
observer.observe(element);
|
|
206
|
+
return () => {
|
|
207
|
+
image.removeEventListener('load', place);
|
|
208
|
+
observer.disconnect();
|
|
209
|
+
};
|
|
210
|
+
}, [image]);
|
|
211
|
+
/** Marks the frame busy for a moment, so the click a touch or a pointer leaves behind reveals nothing (#107). */
|
|
212
|
+
const hold = () => {
|
|
213
|
+
const element = frame.current;
|
|
214
|
+
if (!element)
|
|
215
|
+
return;
|
|
216
|
+
element.dataset['msBusy'] = '';
|
|
217
|
+
if (guard.current)
|
|
218
|
+
clearTimeout(guard.current);
|
|
219
|
+
guard.current = setTimeout(() => {
|
|
220
|
+
if (!element.matches(':focus-within') && !drag.current)
|
|
221
|
+
delete element.dataset['msBusy'];
|
|
222
|
+
}, CONTROLS_GUARD_MS);
|
|
223
|
+
};
|
|
224
|
+
/** The width the note is read at, where the image stands: what a share of the page is a share of. */
|
|
225
|
+
const column = () => {
|
|
226
|
+
const content = frame.current?.closest('.cm-content') ?? null;
|
|
227
|
+
const width = content ? content.clientWidth - parseFloat(getComputedStyle(content).paddingLeft || '0') - parseFloat(getComputedStyle(content).paddingRight || '0') : 0;
|
|
228
|
+
return width > 0 ? Math.round(width) : null;
|
|
229
|
+
};
|
|
230
|
+
/** The widest a share of the page draws the picture: that share of the column, in pixels. */
|
|
231
|
+
const shareWidth = (share) => {
|
|
232
|
+
const wide = column();
|
|
233
|
+
return wide === null ? `${share}%` : `${Math.round((wide * share) / 100)}px`;
|
|
234
|
+
};
|
|
235
|
+
// A share of the page is a share of the column the note is read at (§3.14), as reading mode draws
|
|
236
|
+
// it. Written as a percentage it would resolve against the frame, which shrinks to the picture, and
|
|
237
|
+
// the picture would be drawn at that share of itself (#113): it is given in pixels of the column.
|
|
238
|
+
const percent = size?.percent ?? null;
|
|
239
|
+
useEffect(() => {
|
|
240
|
+
const content = frame.current?.closest('.cm-content') ?? null;
|
|
241
|
+
if (!image || percent === null || !content)
|
|
242
|
+
return;
|
|
243
|
+
const place = () => {
|
|
244
|
+
if (!drag.current)
|
|
245
|
+
image.style.maxWidth = shareWidth(percent);
|
|
246
|
+
};
|
|
247
|
+
place();
|
|
248
|
+
if (typeof ResizeObserver === 'undefined')
|
|
249
|
+
return;
|
|
250
|
+
const observer = new ResizeObserver(place);
|
|
251
|
+
observer.observe(content);
|
|
252
|
+
return () => observer.disconnect();
|
|
253
|
+
}, [image, percent]);
|
|
254
|
+
/** The image as it is drawn now, or `null` before it is laid out. */
|
|
255
|
+
const drawn = () => {
|
|
256
|
+
const box = image?.getBoundingClientRect();
|
|
257
|
+
return box && box.width > 0 && box.height > 0 ? { width: Math.round(box.width), height: Math.round(box.height) } : null;
|
|
258
|
+
};
|
|
259
|
+
/** Its proportion, height over width: its own, else as it is drawn. */
|
|
260
|
+
const ratio = () => {
|
|
261
|
+
const shape = natural ?? drawn();
|
|
262
|
+
return shape ? shape.height / shape.width : null;
|
|
263
|
+
};
|
|
264
|
+
/** 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. */
|
|
265
|
+
const rangeOf = (field) => {
|
|
266
|
+
if (field === 'width' && unit === '%')
|
|
267
|
+
return { min: 1, max: 100 };
|
|
268
|
+
const widest = Math.max(column() ?? 0, natural?.width ?? 0);
|
|
269
|
+
const width = widest > 0 ? widest : MAX_IMAGE_WIDTH;
|
|
270
|
+
if (field === 'width')
|
|
271
|
+
return { min: MIN_IMAGE_SIZE, max: width };
|
|
272
|
+
const proportion = ratio();
|
|
273
|
+
const tallest = Math.max(proportion === null ? 0 : Math.ceil(width * proportion), natural?.height ?? 0);
|
|
274
|
+
return { min: MIN_IMAGE_SIZE, max: tallest > 0 && widest > 0 ? tallest : MAX_IMAGE_WIDTH };
|
|
275
|
+
};
|
|
276
|
+
/** Where the image's source starts in the note, or `null` when it is not in the page. */
|
|
277
|
+
const place = () => {
|
|
117
278
|
const widget = frame.current?.closest('.cm-ms-inline-block, .cm-ms-block');
|
|
118
|
-
|
|
279
|
+
return widget ? view.posAtDOM(widget) : null;
|
|
280
|
+
};
|
|
281
|
+
const apply = (changes) => {
|
|
282
|
+
const widget = frame.current?.closest('.cm-ms-inline-block, .cm-ms-block');
|
|
283
|
+
if (!widget || changes.length === 0)
|
|
119
284
|
return;
|
|
120
285
|
const from = view.posAtDOM(widget);
|
|
121
286
|
// The widget stands where its source was: anything else means the note moved under it.
|
|
122
287
|
if (view.state.sliceDoc(from, from + source.length) !== source)
|
|
123
288
|
return;
|
|
124
|
-
write(view,
|
|
289
|
+
holdInPlace(view, widget, () => write(view, changes.map((change) => ({ ...change, from: change.from + from, to: change.to + from }))));
|
|
290
|
+
};
|
|
291
|
+
const resize = (dimensions) => apply(resizeImage(source, 0, dimensions));
|
|
292
|
+
/** The width it is written or drawn at, in pixels, for a height written beside it. */
|
|
293
|
+
const widthNow = () => size?.width ?? drawn()?.width ?? natural?.width ?? null;
|
|
294
|
+
/** Writes what a field holds, or refuses it in place; `true` when the field may let go of what was typed. */
|
|
295
|
+
const commit = (field) => {
|
|
296
|
+
const text = typed[field];
|
|
297
|
+
if (text === undefined)
|
|
298
|
+
return true;
|
|
299
|
+
if (text.trim() === '') {
|
|
300
|
+
// Empty is "not set": no width is its own size, no height a width alone (#106).
|
|
301
|
+
setTyped((now) => ({ ...now, [field]: undefined }));
|
|
302
|
+
setRefused(null);
|
|
303
|
+
if (field === 'width')
|
|
304
|
+
resize(null);
|
|
305
|
+
else if (size?.width != null)
|
|
306
|
+
resize({ width: size.width, height: null, percent: null });
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
const value = wholeNumber(text);
|
|
310
|
+
const range = rangeOf(field);
|
|
311
|
+
if (value === null || value < range.min || value > range.max) {
|
|
312
|
+
setRefused({ field, range });
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
setRefused(null);
|
|
316
|
+
setTyped((now) => ({ ...now, [field]: undefined }));
|
|
317
|
+
if (field === 'width') {
|
|
318
|
+
if (unit === '%')
|
|
319
|
+
resize({ width: null, height: null, percent: value });
|
|
320
|
+
else if (locked)
|
|
321
|
+
resize({ width: value, height: null, percent: null });
|
|
322
|
+
else
|
|
323
|
+
resize({ width: value, height: size?.height ?? drawn()?.height ?? null, percent: null });
|
|
324
|
+
}
|
|
325
|
+
else if (locked) {
|
|
326
|
+
// A height typed under the lock sets the width that draws it, and only the width is written.
|
|
327
|
+
const proportion = ratio();
|
|
328
|
+
if (proportion !== null)
|
|
329
|
+
resize({ width: Math.max(1, Math.round(value / proportion)), height: null, percent: null });
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
const width = widthNow();
|
|
333
|
+
if (width !== null)
|
|
334
|
+
resize({ width, height: value, percent: null });
|
|
335
|
+
}
|
|
336
|
+
return true;
|
|
125
337
|
};
|
|
126
|
-
const
|
|
127
|
-
|
|
338
|
+
const commitDescription = () => {
|
|
339
|
+
const text = typed.description;
|
|
340
|
+
if (text === undefined)
|
|
128
341
|
return;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if (typed.trim() === '')
|
|
132
|
-
resize(null);
|
|
133
|
-
else if (Number.isFinite(width) && width >= 1)
|
|
134
|
-
resize(width);
|
|
342
|
+
setTyped((now) => ({ ...now, description: undefined }));
|
|
343
|
+
apply(describeImage(source, 0, text));
|
|
135
344
|
};
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
event.
|
|
139
|
-
|
|
345
|
+
const field = (name) => ({
|
|
346
|
+
onChange: (event) => {
|
|
347
|
+
const value = event.target.value;
|
|
348
|
+
setTyped((now) => ({ ...now, [name]: value }));
|
|
349
|
+
if (refused?.field === name)
|
|
350
|
+
setRefused(null);
|
|
351
|
+
},
|
|
352
|
+
// Whatever takes it there — a click, a double click, Tab — the value is selected, so what is typed replaces it (#105).
|
|
353
|
+
onFocus: (event) => event.currentTarget.select(),
|
|
354
|
+
onMouseUp: (event) => {
|
|
355
|
+
const input = event.currentTarget;
|
|
356
|
+
if (document.activeElement === input && input.selectionStart === input.selectionEnd && typed[name] === undefined) {
|
|
357
|
+
event.preventDefault();
|
|
358
|
+
input.select();
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
onDoubleClick: (event) => event.currentTarget.select(),
|
|
362
|
+
onKeyDown: (event) => {
|
|
363
|
+
if (event.key === 'Enter') {
|
|
364
|
+
event.preventDefault();
|
|
365
|
+
if (name === 'description')
|
|
366
|
+
commitDescription();
|
|
367
|
+
else if (commit(name))
|
|
368
|
+
event.currentTarget.select();
|
|
369
|
+
}
|
|
370
|
+
else if (event.key === 'Escape') {
|
|
371
|
+
event.preventDefault();
|
|
372
|
+
setTyped((now) => ({ ...now, [name]: undefined }));
|
|
373
|
+
setRefused(null);
|
|
374
|
+
}
|
|
375
|
+
},
|
|
376
|
+
onBlur: () => {
|
|
377
|
+
if (name === 'description')
|
|
378
|
+
commitDescription();
|
|
379
|
+
// A value refused is let go when the field is left, and the note keeps what it held.
|
|
380
|
+
else if (!commit(name)) {
|
|
381
|
+
setTyped((now) => ({ ...now, [name]: undefined }));
|
|
382
|
+
setRefused(null);
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
const toggleLock = () => {
|
|
387
|
+
const next = !locked;
|
|
388
|
+
setLocked(next);
|
|
389
|
+
// Locked, the height follows the width: one written beside it goes (#106).
|
|
390
|
+
if (next && size?.height != null && size.width !== null)
|
|
391
|
+
resize({ width: size.width, height: null, percent: null });
|
|
392
|
+
};
|
|
393
|
+
const chooseUnit = (next) => {
|
|
394
|
+
setUnit(next);
|
|
395
|
+
setTyped((now) => ({ ...now, width: undefined }));
|
|
396
|
+
setRefused(null);
|
|
397
|
+
const now = drawn();
|
|
398
|
+
const wide = column();
|
|
399
|
+
// The size it is drawn at, said in the other unit, when it has one.
|
|
400
|
+
if (next === '%' && size?.percent == null && size?.width != null && now && wide) {
|
|
401
|
+
const share = Math.min(100, Math.max(1, Math.round((now.width / wide) * 100)));
|
|
402
|
+
const at = place();
|
|
403
|
+
if (at !== null)
|
|
404
|
+
switchedUnits.set(view, { at, percent: share, width: size.width, height: size.height });
|
|
405
|
+
resize({ width: null, height: null, percent: share });
|
|
140
406
|
}
|
|
141
|
-
else if (
|
|
142
|
-
|
|
143
|
-
|
|
407
|
+
else if (next === 'px' && size?.percent != null) {
|
|
408
|
+
const left = switchedUnits.get(view);
|
|
409
|
+
if (left && left.at === place() && left.percent === size.percent) {
|
|
410
|
+
resize({ width: left.width, height: left.height, percent: null });
|
|
411
|
+
setLocked(left.height === null);
|
|
412
|
+
}
|
|
413
|
+
else if (now)
|
|
414
|
+
resize({ width: now.width, height: null, percent: null });
|
|
144
415
|
}
|
|
416
|
+
if (next === 'px')
|
|
417
|
+
switchedUnits.delete(view);
|
|
418
|
+
if (next === '%')
|
|
419
|
+
setLocked(true);
|
|
420
|
+
};
|
|
421
|
+
const free = (!locked && unit === 'px') || shift;
|
|
422
|
+
const shownWidth = dragged ? (unit === '%' ? percentOf(dragged.width) : dragged.width) : unit === '%' ? size?.percent ?? null : size?.width ?? null;
|
|
423
|
+
const shownHeight = dragged ? dragged.height : size?.height ?? (locked && size?.width != null && ratio() !== null ? Math.round(size.width * ratio()) : null);
|
|
424
|
+
function percentOf(width) {
|
|
425
|
+
const wide = column();
|
|
426
|
+
return wide ? Math.min(100, Math.max(1, Math.round((width / wide) * 100))) : null;
|
|
427
|
+
}
|
|
428
|
+
const endDrag = (event, cancelled) => {
|
|
429
|
+
const start = drag.current;
|
|
430
|
+
if (!start || start.pointer !== event.pointerId)
|
|
431
|
+
return;
|
|
432
|
+
drag.current = null;
|
|
433
|
+
const end = drawn();
|
|
434
|
+
setDragged(null);
|
|
435
|
+
if (image) {
|
|
436
|
+
image.style.width = '';
|
|
437
|
+
image.style.height = '';
|
|
438
|
+
image.style.maxWidth = percent === null ? '' : shareWidth(percent);
|
|
439
|
+
}
|
|
440
|
+
hold();
|
|
441
|
+
if (cancelled || !end)
|
|
442
|
+
return;
|
|
443
|
+
if (Math.abs(end.width - start.width) < 1 && Math.abs(end.height - start.height) < 1)
|
|
444
|
+
return;
|
|
445
|
+
if (unit === '%') {
|
|
446
|
+
const percent = percentOf(end.width);
|
|
447
|
+
if (percent !== null)
|
|
448
|
+
resize({ width: null, height: null, percent });
|
|
449
|
+
}
|
|
450
|
+
else if (free || event.shiftKey)
|
|
451
|
+
resize({ width: end.width, height: end.height, percent: null });
|
|
452
|
+
else
|
|
453
|
+
resize({ width: end.width, height: null, percent: null });
|
|
454
|
+
};
|
|
455
|
+
/** Shows the image's notation, as a table's button does: the caret goes before it (#112). */
|
|
456
|
+
const showSource = () => {
|
|
457
|
+
const widget = frame.current?.closest('.cm-ms-inline-block, .cm-ms-block');
|
|
458
|
+
if (widget)
|
|
459
|
+
reveal(view, view.posAtDOM(widget));
|
|
145
460
|
};
|
|
146
|
-
const shown = dragged ?? size?.width ?? null;
|
|
147
461
|
const offered = size !== null && image !== null;
|
|
148
|
-
|
|
462
|
+
const unitWord = unit === '%' ? '%' : 'px';
|
|
463
|
+
const invalid = (name) => (refused?.field === name ? { 'aria-invalid': true, title: words.imageOutOfRange(refused.range.min, refused.range.max, name === 'width' ? unitWord : 'px') } : {});
|
|
464
|
+
return (_jsxs("span", { ref: frame, className: "edit-image", "data-resizing": dragged === null ? undefined : '', "data-ms-in-place": offered ? '' : undefined, onFocus: () => frame.current && (frame.current.dataset['msBusy'] = ''), onBlur: hold, onMouseDown: (event) => {
|
|
465
|
+
// The frame's own area is the space between the picture and its controls: a press there is
|
|
466
|
+
// one meant for them, and shows nothing (#107).
|
|
467
|
+
if (event.target === event.currentTarget) {
|
|
468
|
+
event.preventDefault();
|
|
469
|
+
hold();
|
|
470
|
+
}
|
|
471
|
+
}, children: [children, offered && (_jsxs(_Fragment, { children: [_jsxs("span", { className: "edit-image-controls", "data-ms-control": "", onPointerDown: hold, onTouchStart: hold, onMouseDown: (event) => {
|
|
472
|
+
// A press anywhere but on a field, a choice or a button — a gap between them, the
|
|
473
|
+
// padding, a word beside a field — keeps the focus where it is. Its default would put
|
|
474
|
+
// the editor's caret where the press fell, and the image would show its source (#107).
|
|
475
|
+
if (!event.target.closest('input, select, textarea, button'))
|
|
476
|
+
event.preventDefault();
|
|
477
|
+
}, 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') })) : (
|
|
478
|
+
// An embed writes no description (§5.8): the file's own is shown, and is not edited here.
|
|
479
|
+
_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 }), _jsx(Control, { label: words.source, onClick: showSource, children: ICONS.source })] }), 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
480
|
event.preventDefault();
|
|
481
|
+
event.stopPropagation();
|
|
482
|
+
const now = drawn();
|
|
483
|
+
if (!now)
|
|
484
|
+
return;
|
|
150
485
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
151
|
-
drag.current = { x: event.clientX,
|
|
152
|
-
|
|
486
|
+
drag.current = { x: event.clientX, y: event.clientY, ...now, pointer: event.pointerId };
|
|
487
|
+
if (frame.current)
|
|
488
|
+
frame.current.dataset['msBusy'] = '';
|
|
489
|
+
setShift(event.shiftKey);
|
|
490
|
+
setDragged(now);
|
|
153
491
|
}, onPointerMove: (event) => {
|
|
154
492
|
const start = drag.current;
|
|
155
|
-
if (!start || start.pointer !== event.pointerId)
|
|
493
|
+
if (!image || !start || start.pointer !== event.pointerId)
|
|
156
494
|
return;
|
|
157
|
-
|
|
495
|
+
setShift(event.shiftKey);
|
|
496
|
+
const width = Math.max(MIN_IMAGE_SIZE, Math.round(start.width + event.clientX - start.x));
|
|
497
|
+
image.style.maxWidth = '100%';
|
|
158
498
|
image.style.width = `${width}px`;
|
|
159
|
-
|
|
499
|
+
if ((!locked && unit === 'px') || event.shiftKey) {
|
|
500
|
+
image.style.height = `${Math.max(MIN_IMAGE_SIZE, Math.round(start.height + event.clientY - start.y))}px`;
|
|
501
|
+
}
|
|
502
|
+
else
|
|
503
|
+
image.style.height = 'auto';
|
|
160
504
|
// 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
|
-
} })] }))] }));
|
|
505
|
+
setDragged(drawn());
|
|
506
|
+
}, onPointerUp: (event) => endDrag(event, false), onPointerCancel: (event) => endDrag(event, true), children: ICON_RESIZE })] }))] }));
|
|
179
507
|
}
|
|
180
508
|
// ── Tables ───────────────────────────────────────────────────────────────────
|
|
181
509
|
/** Where the focus goes back to when a table is drawn again after an edit of one of its cells. */
|
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[];
|