// Where an image goes, decided when the reader dropped it rather than when the network came back. // // An upload takes as long as it takes, and the editor stays editable while it runs — that is the // point of uploading in the background. So the caret is not a place: by the time the blob and its // record are written, the reader may have typed three lines above the drop, taken the caret // somewhere else, or dropped a second file. `MarkdownEditor` therefore fixes an insertion point at // the moment of the drop, paste or pick and carries it forward across everything that happened // since. Its CodeMirror half gets that from CodeMirror, which maps positions through its own change // sets; its textarea half has no change set and gets it from `remap()` below, against the two // strings. // // Both rules are here, pure, so they can be held without a DOM. import { imageMarkdownSpansIn } from '@radial/core' /** * Where `pos` in `before` ends up in `after`, given that the reader edited one stretch of it. * * A textarea reports a new string, not a change, so the edit is recovered as the one span between * the common prefix and the common suffix — which is what a person typing, pasting or deleting in * one place actually did, and the worst a coarser guess costs is an image landing at the end of an * edit rather than inside it. * * The position associates FORWARD, matching the `assoc: 1` the CodeMirror half maps with: text * inserted exactly at the insertion point belongs before the image, and the image this component * just wrote leaves the point after itself, ready for the next file of the same drop. */ export function remap(before: string, after: string, pos: number): number { const at = Math.max(0, Math.min(pos, before.length)) if (before === after) return at const limit = Math.min(before.length, after.length) let head = 0 while (head < limit && before[head] === after[head]) head += 1 let tail = 0 while ( tail < limit - head && before[before.length - 1 - tail] === after[after.length - 1 - tail] ) { tail += 1 } // After the edit, so it moves by what the edit added or removed; before it, so it does not move; // inside it, so it goes to where the edit ends — the text it used to sit in is gone. if (at >= before.length - tail) return at + (after.length - before.length) if (at <= head) return at return after.length - tail } /** * If `from`–`to` is parked strictly inside a Radial image, the offset just past that image. * * The editor selects each new image's alt text so the reader can correct the filename it was guessed * from — and typing over that selection leaves a collapsed caret between `![` and `](`, inside the * markdown. Inserting the next image there would nest the two, which `marked` lexes as a single * image whose alt is the literal text of the other one: the second picture never renders, and the * body that gets signed says something no one wrote. So the state this component itself creates is * the one state that cannot be an insertion point, and it is recognised positionally rather than by * remembering a range, which stops holding the moment the reader edits anything. */ export function afterContainingImage(doc: string, from: number, to: number): number | undefined { for (const { from: start, to: end } of imageMarkdownSpansIn(doc)) { if ((from > start && from < end) || (to > start && to < end)) return end } return undefined }