@aicayzer/inkkit 0.0.14 → 0.0.16

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/dist/preserve.js CHANGED
@@ -323,27 +323,89 @@ function withoutHeadingId(node) {
323
323
  ? node.type.create({ ...node.attrs, id: '' }, node.content, node.marks)
324
324
  : node;
325
325
  }
326
- // Edit distance distinguishes replacements from insertions, so replacing a block
327
- // retains its surrounding whitespace without assigning it a neighbour's source.
328
- function correspondence(before, after) {
326
+ const MATCHING_BUDGET = 1_000_000;
327
+ // Unchanged blocks are anchored first, so a long document with many scattered
328
+ // edits is matched as many short gaps rather than one wide span.
329
+ export function correspondence(before, after) {
329
330
  const result = new Array(after.length);
330
- let head = 0;
331
- while (head < before.length &&
332
- head < after.length &&
333
- before[head] === after[head]) {
334
- result[head] = head;
335
- head++;
331
+ const work = { budget: MATCHING_BUDGET };
332
+ const spans = [{ b: 0, a: 0, bEnd: before.length, aEnd: after.length }];
333
+ for (let span = spans.pop(); span; span = spans.pop()) {
334
+ let { b, a, bEnd, aEnd } = span;
335
+ while (b < bEnd && a < aEnd && before[b] === after[a])
336
+ result[a++] = b++;
337
+ while (b < bEnd && a < aEnd && before[bEnd - 1] === after[aEnd - 1])
338
+ result[--aEnd] = --bEnd;
339
+ if (b === bEnd || a === aEnd)
340
+ continue;
341
+ work.budget -= bEnd - b + (aEnd - a);
342
+ if (work.budget < 0)
343
+ throw tooManyBlocks();
344
+ const anchors = uniqueAnchors(before, after, b, bEnd, a, aEnd);
345
+ if (!anchors.length) {
346
+ align(before, after, b, bEnd, a, aEnd, result, work);
347
+ continue;
348
+ }
349
+ for (const anchor of anchors) {
350
+ result[anchor.a] = anchor.b;
351
+ spans.push({ b, a, bEnd: anchor.b, aEnd: anchor.a });
352
+ b = anchor.b + 1;
353
+ a = anchor.a + 1;
354
+ }
355
+ spans.push({ b, a, bEnd, aEnd });
356
+ }
357
+ return result;
358
+ }
359
+ function tooManyBlocks() {
360
+ return new PreservationError('The edit changes too many blocks to preserve safely');
361
+ }
362
+ // Blocks that occur exactly once on each side, in the longest subsequence whose
363
+ // order the two sides share. A repeated block is never an anchor: which copy it
364
+ // belongs to is not known until its neighbours are placed.
365
+ function uniqueAnchors(before, after, b, bEnd, a, aEnd) {
366
+ const once = (list, from, to) => {
367
+ const at = new Map();
368
+ for (let i = from; i < to; i++)
369
+ at.set(list[i], at.has(list[i]) ? -1 : i);
370
+ return at;
371
+ };
372
+ const beforeAt = once(before, b, bEnd);
373
+ const afterAt = once(after, a, aEnd);
374
+ const pairs = [];
375
+ for (let i = a; i < aEnd; i++) {
376
+ const match = beforeAt.get(after[i]);
377
+ if (match != null && match >= 0 && afterAt.get(after[i]) === i)
378
+ pairs.push({ b: match, a: i });
379
+ }
380
+ // Patience sorting: tails[k] ends the best increasing subsequence of length k + 1.
381
+ const tails = [];
382
+ const previous = new Array(pairs.length);
383
+ for (const [index, pair] of pairs.entries()) {
384
+ let low = 0;
385
+ let high = tails.length;
386
+ while (low < high) {
387
+ const middle = (low + high) >> 1;
388
+ if (pairs[tails[middle]].b < pair.b)
389
+ low = middle + 1;
390
+ else
391
+ high = middle;
392
+ }
393
+ previous[index] = low ? tails[low - 1] : -1;
394
+ tails[low] = index;
336
395
  }
337
- let endBefore = before.length;
338
- let endAfter = after.length;
339
- while (endBefore > head &&
340
- endAfter > head &&
341
- before[endBefore - 1] === after[endAfter - 1]) {
342
- result[--endAfter] = --endBefore;
396
+ const anchors = [];
397
+ let at = tails.length ? tails[tails.length - 1] : -1;
398
+ while (at >= 0) {
399
+ anchors.push(pairs[at]);
400
+ at = previous[at];
343
401
  }
344
- const rows = endBefore - head + 1;
345
- const columns = endAfter - head + 1;
346
- let budget = 1_000_000;
402
+ return anchors.reverse();
403
+ }
404
+ // Edit distance distinguishes replacements from insertions, so replacing a block
405
+ // retains its surrounding whitespace without assigning it a neighbour's source.
406
+ function align(before, after, b, bEnd, a, aEnd, result, work) {
407
+ const rows = bEnd - b + 1;
408
+ const columns = aEnd - a + 1;
347
409
  let width = Math.max(1, Math.abs(rows - columns));
348
410
  for (;;) {
349
411
  const costs = [];
@@ -353,13 +415,13 @@ function correspondence(before, after) {
353
415
  for (let i = 0; i < rows; i++) {
354
416
  const from = Math.max(0, i - width);
355
417
  const to = Math.min(columns - 1, i + width);
356
- budget -= to - from + 1;
357
- if (budget < 0)
358
- throw new PreservationError('The edit changes too many blocks to preserve safely');
418
+ work.budget -= to - from + 1;
419
+ if (work.budget < 0)
420
+ throw tooManyBlocks();
359
421
  const row = new Uint32Array(to - from + 1);
360
422
  costs.push(row);
361
423
  for (let j = from; j <= to; j++) {
362
- const change = before[head + i - 1] === after[head + j - 1] ? 0 : 1;
424
+ const change = before[b + i - 1] === after[a + j - 1] ? 0 : 1;
363
425
  row[j - from] =
364
426
  i === 0
365
427
  ? j
@@ -377,9 +439,9 @@ function correspondence(before, after) {
377
439
  let i = rows - 1;
378
440
  let j = columns - 1;
379
441
  while (i || j) {
380
- const change = i && j && before[head + i - 1] === after[head + j - 1] ? 0 : 1;
442
+ const change = i && j && before[b + i - 1] === after[a + j - 1] ? 0 : 1;
381
443
  if (i && j && get(i, j) === get(i - 1, j - 1) + change) {
382
- result[head + j - 1] = head + i - 1;
444
+ result[a + j - 1] = b + i - 1;
383
445
  i--;
384
446
  j--;
385
447
  }
@@ -388,9 +450,8 @@ function correspondence(before, after) {
388
450
  else
389
451
  j--;
390
452
  }
391
- break;
453
+ return;
392
454
  }
393
- return result;
394
455
  }
395
456
  /** Retains the loaded source of blocks the edit leaves alone. */
396
457
  export class Preservation {
package/dist/search.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Node as ProseNode } from '@milkdown/kit/prose/model';
1
+ import { type Transaction } from '@milkdown/kit/prose/state';
2
2
  import type { EditorView } from '@milkdown/kit/prose/view';
3
3
  export interface TextMatch {
4
4
  from: number;
@@ -11,5 +11,5 @@ export interface LiteralReplacement extends TextMatch {
11
11
  export declare function findLiteral(text: string, search: string, from?: number): TextMatch | undefined;
12
12
  export declare function replaceLiteral(text: string, search: string, replacement: string, selection: TextMatch, all?: boolean): LiteralReplacement;
13
13
  export declare function findFormatted(view: EditorView, text: string): boolean;
14
- export declare function replaceFormatted(view: EditorView, search: string, replacement: string, preflight: (doc: ProseNode) => void, all?: boolean): number;
15
- export declare function replaceFormattedRange(view: EditorView, from: number, to: number, replacement: string, preflight: (doc: ProseNode) => void): boolean;
14
+ export declare function replaceFormatted(view: EditorView, search: string, replacement: string, preflight: (tr: Transaction) => void, all?: boolean): number;
15
+ export declare function replaceFormattedRange(view: EditorView, from: number, to: number, replacement: string, preflight: (tr: Transaction) => void): boolean;
package/dist/search.js CHANGED
@@ -162,7 +162,7 @@ export function replaceFormatted(view, search, replacement, preflight, all = fal
162
162
  tr = tr.setSelection(TextSelection.create(tr.doc, match.from, tr.mapping.map(match.to, 1)));
163
163
  }
164
164
  try {
165
- preflight(tr.doc);
165
+ preflight(tr);
166
166
  }
167
167
  catch (error) {
168
168
  if (error instanceof InkKitError)
@@ -189,7 +189,7 @@ export function replaceFormattedRange(view, from, to, replacement, preflight) {
189
189
  const tr = closeHistory(view.state.tr.replace(from, to, replacementSlice(view.state, from, to, value)));
190
190
  tr.setSelection(TextSelection.create(tr.doc, from, tr.mapping.map(to, 1)));
191
191
  try {
192
- preflight(tr.doc);
192
+ preflight(tr);
193
193
  }
194
194
  catch (error) {
195
195
  if (error instanceof InkKitError)
package/dist/style.css CHANGED
@@ -201,6 +201,54 @@
201
201
  max-width: 100%;
202
202
  vertical-align: bottom;
203
203
  }
204
+ /* A standalone image is its own block; the row is full width so a click beside the card lands on it. */
205
+ .inkkit-root .ProseMirror .inkkit-image-block {
206
+ display: block;
207
+ margin: 0 0
208
+ var(--inkkit-paragraph-spacing, calc(var(--inkkit-font-size, 15px) * 0.667));
209
+ line-height: 0;
210
+ }
211
+ .inkkit-root .ProseMirror .inkkit-image-block > .image {
212
+ line-height: normal;
213
+ }
214
+ /* A card that shares its paragraph with text sits on its own line within it. */
215
+ .inkkit-root .ProseMirror :not(.inkkit-image-block) > .image.inkkit-file {
216
+ display: block;
217
+ width: fit-content;
218
+ }
219
+ .inkkit-root .ProseMirror .inkkit-image-block.ProseMirror-selectednode {
220
+ outline: none;
221
+ }
222
+ .inkkit-root
223
+ .ProseMirror
224
+ .inkkit-image-block.ProseMirror-selectednode
225
+ > .image {
226
+ outline: 2px solid color-mix(in srgb, var(--_inkkit-accent) 60%, transparent);
227
+ outline-offset: 2px;
228
+ border-radius: 6px;
229
+ }
230
+ .inkkit-root .ProseMirror-gapcursor {
231
+ display: none;
232
+ pointer-events: none;
233
+ position: absolute;
234
+ }
235
+ .inkkit-root .ProseMirror-gapcursor::after {
236
+ content: '';
237
+ display: block;
238
+ position: absolute;
239
+ top: -2px;
240
+ width: 20px;
241
+ border-top: 2px solid var(--_inkkit-accent);
242
+ animation: inkkit-gapcursor-blink 1.1s steps(2, start) infinite;
243
+ }
244
+ @keyframes inkkit-gapcursor-blink {
245
+ to {
246
+ visibility: hidden;
247
+ }
248
+ }
249
+ .inkkit-root .ProseMirror-focused .ProseMirror-gapcursor {
250
+ display: block;
251
+ }
204
252
  .inkkit-root .ProseMirror .image img {
205
253
  display: block;
206
254
  max-width: 100%;
@@ -222,6 +270,7 @@
222
270
  position: absolute;
223
271
  right: 0;
224
272
  top: 50%;
273
+ z-index: 1;
225
274
  transform: translateY(-50%);
226
275
  width: 20px;
227
276
  height: 32px;
@@ -255,7 +304,7 @@
255
304
  vertical-align: middle;
256
305
  border: 1px solid var(--_inkkit-rule);
257
306
  border-radius: 6px;
258
- padding: 8px;
307
+ padding: 6px;
259
308
  white-space: normal;
260
309
  }
261
310
 
@@ -263,13 +312,30 @@
263
312
  display: flex;
264
313
  flex-wrap: wrap;
265
314
  align-items: center;
266
- gap: 0.5em;
315
+ gap: 0.25em 0.5em;
267
316
  font-size: 0.9em;
268
317
  line-height: 1.4;
269
318
  color: var(--_inkkit-muted);
270
319
  }
271
320
  .inkkit-root .ProseMirror .inkkit-file-footer:not(:first-child) {
272
- margin-block-start: 0.5em;
321
+ margin-block-start: 0.25em;
322
+ }
323
+ /* Below a picture, the name takes the picture's width rather than widening the card, so the grip
324
+ stays at the picture's edge; a short picture still leaves room for a few characters. */
325
+ .inkkit-root .ProseMirror .inkkit-image-preview ~ .inkkit-file-footer {
326
+ width: 0;
327
+ min-width: 100%;
328
+ }
329
+ .inkkit-root .ProseMirror .image.inkkit-file:has(> .inkkit-image-preview) {
330
+ min-width: min(100%, 8em);
331
+ }
332
+ /* The name truncates; the open arrow stays visible right after it. */
333
+ .inkkit-root .ProseMirror .inkkit-file-name {
334
+ display: flex;
335
+ flex: 1 1 auto;
336
+ align-items: center;
337
+ gap: 0.25em;
338
+ min-width: 0;
273
339
  }
274
340
 
275
341
  .inkkit-root .ProseMirror .inkkit-file audio,
@@ -290,7 +356,7 @@
290
356
  }
291
357
 
292
358
  .inkkit-root .ProseMirror .inkkit-file-label {
293
- flex: 1 1 auto;
359
+ flex: 0 1 auto;
294
360
  min-width: 0;
295
361
  overflow: hidden;
296
362
  text-overflow: ellipsis;
@@ -313,6 +379,34 @@
313
379
  border-radius: 3px;
314
380
  cursor: pointer;
315
381
  }
382
+ .inkkit-root .ProseMirror .inkkit-file-open {
383
+ display: inline-flex;
384
+ align-items: center;
385
+ justify-content: center;
386
+ min-width: 1.5em;
387
+ min-height: 1.5em;
388
+ padding: 0;
389
+ color: var(--_inkkit-muted);
390
+ background: transparent;
391
+ border-color: transparent;
392
+ }
393
+ .inkkit-root .ProseMirror .inkkit-file-open:hover {
394
+ color: var(--_inkkit-text);
395
+ background: var(--_inkkit-code-bg);
396
+ }
397
+ .inkkit-root .ProseMirror .inkkit-file-open:focus-visible {
398
+ outline: 2px solid var(--_inkkit-accent);
399
+ outline-offset: 1px;
400
+ }
401
+ .inkkit-root .ProseMirror .inkkit-file-open svg {
402
+ width: 0.85em;
403
+ height: 0.85em;
404
+ fill: none;
405
+ stroke: currentColor;
406
+ stroke-width: 1.5;
407
+ stroke-linecap: round;
408
+ stroke-linejoin: round;
409
+ }
316
410
 
317
411
  .inkkit-root .ProseMirror {
318
412
  /* The viewport, not the wrapper Milkdown adds, so a click below the text still places the caret. */
@@ -1,5 +1,6 @@
1
1
  import { InkKitError } from './types.js';
2
2
  import { fileReference } from './linked-syntax.js';
3
+ import { imageBlockName } from './image-blocks.js';
3
4
  export function readableProjection(doc) {
4
5
  const result = { text: '', spans: [] };
5
6
  let blocks = 0;
@@ -24,6 +25,11 @@ export function readableProjection(doc) {
24
25
  String(node.attrs.kind).replace(/^./, (value) => value.toUpperCase()), position, position + 1, 'embed');
25
26
  return true;
26
27
  }
28
+ if (name === imageBlockName) {
29
+ block(position);
30
+ append(fileReference(node).label || 'Image', position, position + node.nodeSize, 'embed');
31
+ return false;
32
+ }
27
33
  if (node.isTextblock) {
28
34
  block(position + 1);
29
35
  if (!node.content.size)
@@ -128,9 +134,12 @@ export function formattedRects(view, projection, from, to) {
128
134
  if (span.kind === 'embed' || span.kind === 'break') {
129
135
  const node = view.nodeDOM(span.start);
130
136
  if (node instanceof Element) {
137
+ // A standalone image's node is its full-width row; the card is what shows.
131
138
  const label = node.matches('.inkkit-callout')
132
139
  ? (node.querySelector(':scope > .inkkit-callout-header > .inkkit-callout-title') ?? node)
133
- : node;
140
+ : node.matches('.inkkit-image-block')
141
+ ? (node.firstElementChild ?? node)
142
+ : node;
134
143
  rects.push(...Array.from(label.getClientRects(), textRect));
135
144
  }
136
145
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicayzer/inkkit",
3
- "version": "0.0.14",
3
+ "version": "0.0.16",
4
4
  "description": "A preserving Markdown editor for offline web views and the web",
5
5
  "license": "MIT",
6
6
  "type": "module",