rapier-markdown-kit 1.1.87 → 1.1.89

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 CHANGED
@@ -11,8 +11,9 @@ and colour.
11
11
  - Will/1 markers say which regions an agent may edit, only add to, or must keep.
12
12
  - MIT, no dependencies, Node 22 or newer.
13
13
 
14
- To hand a person the whole editor around their document as one offline file, run `npx rapier-html notes.md`.
15
- The same command accepts a drawing or an SVG.
14
+ To show documents in your app without writing a renderer, frame the read-only reader with
15
+ `npm install rapier-embed` (`build: 'reader'`). To hand a person the whole editor around their document as one
16
+ offline file, run `npx rapier-html notes.md`.
16
17
 
17
18
  ```sh
18
19
  npm install rapier-markdown-kit
@@ -237,7 +238,10 @@ console.log('lines:', plan.lines.length, 'height:', plan.height); // lines: 4 he
237
238
 
238
239
  A heading, which the style pack balances, takes its font size as an eighth argument
239
240
  (`flowLines(flow, width, top, obstacles, lineHeight, minSlot, direction, fontSize)`): its lines come out as
240
- Chromium lays `text-wrap: balance`, and greedy wherever an obstacle narrows a line.
241
+ Chromium lays `text-wrap: balance`, and greedy wherever an obstacle narrows a line. The block's `text-align` is the ninth
242
+ argument (`'start'` by default). A picture moves a block's words only where its outline meets them: when no line of the
243
+ block, laid and aligned in the whole column as with no picture, meets an obstacle, the plan is that layout (`x` 0, the
244
+ column's width); otherwise every line flows around the obstacles.
241
245
 
242
246
  ## What the host supplies
243
247
 
@@ -302,7 +306,7 @@ const together = merge(ledger, reopened.ledger);
302
306
  console.assert(together.text === 'Hello');
303
307
  ```
304
308
 
305
- ## The format at launch
309
+ ## The format
306
310
 
307
311
  `rapier-ledger/1` is a plain JSON object:
308
312
 
@@ -334,8 +338,7 @@ actual document. `historyEnvelope` additionally proves the editor's Undo/Redo br
334
338
  `chars:fnv-base36:adler-base36` initial root. Each splice advances it with
335
339
  `rootAfter(priorRoot, splice)`, matching the source store. A later root is **not**
336
340
  a fresh hash of the whole current text. SHA-256 of the exact checkpoint text is
337
- separate. This distinction preserves the editor's actual format rather than the
338
- earlier design shorthand. A nonzero start names a retained checkpoint; it does not
341
+ separate. A nonzero start names a retained checkpoint; it does not
339
342
  claim to reconstruct discarded events. `complete: false` also permits a stated
340
343
  revision gap from the editor's pruning of cancelling navigation pairs; each
341
344
  remaining splice must still replay exactly. It is never a claim about missing edits.
@@ -1030,7 +1030,7 @@ function _rapierArtifactFactoryModules(dependencies, group) {
1030
1030
  function _rapierProjectArtifactLayout(root, metadata, geometry, pretext) {
1031
1031
  if (!root || !metadata || !geometry || !pretext) return null;
1032
1032
  const doc = root.ownerDocument, view = doc.defaultView;
1033
- const styles = new Map(), originals = new Map(), spacers = [], floats = [], profiles = new WeakMap();
1033
+ const styles = new Map(), originals = new Map(), spacers = [], floats = [], steps = [], profiles = new WeakMap();
1034
1034
  const css = node => view.getComputedStyle(node);
1035
1035
  const box = node => node.getBoundingClientRect();
1036
1036
  const pixels = value => (Number.isFinite(value) ? Math.round(value * 100) / 100 : 0) + 'px';
@@ -1053,6 +1053,8 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, pretext) {
1053
1053
  spacers.length = 0;
1054
1054
  for (const float of floats) float.remove();
1055
1055
  floats.length = 0;
1056
+ for (const item of steps) item.removeAttribute('data-rapier-wrap-step');
1057
+ steps.length = 0;
1056
1058
  }
1057
1059
 
1058
1060
  // Plain inline text uses Pretext. Complex blocks keep their original controls through floats.
@@ -1116,7 +1118,7 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, pretext) {
1116
1118
 
1117
1119
  function project(record, width, top, obstacles) {
1118
1120
  const plan = geometry.flowLines(record.flow, width, top, obstacles, record.lineHeight,
1119
- metadata.wrapColumnFloor(record.fontSize), record.direction, record.balance);
1121
+ metadata.wrapColumnFloor(record.fontSize), record.direction, record.balance, record.align);
1120
1122
  if (!plan) return false;
1121
1123
  const output = doc.createDocumentFragment();
1122
1124
  let previous = null;
@@ -1210,14 +1212,14 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, pretext) {
1210
1212
  }
1211
1213
  }
1212
1214
 
1213
- // Nothing is ever inserted between a picture and its words: a reader and the witnesses read nextElementSibling.
1214
- function clearTo(element, bottom, origin, before = element) {
1215
- const distance = bottom - (box(element).top - origin);
1216
- if (distance <= 0) return;
1217
- const spacer = doc.createElement('div');
1218
- spacer.setAttribute('aria-hidden', 'true');
1219
- spacer.style.cssText = 'height:' + pixels(distance) + ';clear:both;';
1220
- before.before(spacer); spacers.push(spacer);
1215
+ // A block that cannot wrap stands with its box just below the outline, as in the editor. Its own margin moves it, so
1216
+ // nothing is inserted between a picture and its words (a reader and the witnesses read nextElementSibling).
1217
+ function clearTo(element, bottom, origin) {
1218
+ for (let pass = 0; pass < 3; pass++) {
1219
+ const distance = bottom - (box(element).top - origin);
1220
+ if (Math.abs(distance) <= 0.5) return;
1221
+ style(element, {'margin-top': pixels(Math.max(0, (parseFloat(css(element).marginTop) || 0) + distance))});
1222
+ }
1221
1223
  }
1222
1224
 
1223
1225
  const imageOnly = geometry.imageOnly;
@@ -1234,7 +1236,27 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, pretext) {
1234
1236
  // Float fallback as the editor's floatAround.
1235
1237
  const FLOAT_BLOCK = /^(P|H[1-6]|UL|OL|BLOCKQUOTE|DETAILS|SUMMARY|DL)$/;
1236
1238
  function floatAround(element, width, height, top, obstacles) {
1237
- if (!FLOAT_BLOCK.test(element.tagName) || element.querySelector('table, pre, figure, img, .math-rendered')) return false;
1239
+ // As the editor: a paragraph or heading floats whatever it holds; a list, quote or disclosure holding a rigid block does not.
1240
+ if (!FLOAT_BLOCK.test(element.tagName) || !/^(P|H[1-6])$/.test(element.tagName) && element.querySelector('table, pre, figure, img, .math-rendered')) return false;
1241
+ // As the editor: a disclosure's box ends where the outline begins (layout/model.mjs boxClearance).
1242
+ if (element.tagName === 'DETAILS') {
1243
+ const computed = css(element), outer = box(element), floor = metadata.wrapColumnFloor(parseFloat(computed.fontSize) || 16);
1244
+ const offset = (parseFloat(computed.paddingLeft) || 0) + (parseFloat(computed.borderLeftWidth) || 0);
1245
+ const own = obstacles.map(obstacle => ({...obstacle, x: obstacle.x + offset}));
1246
+ const marginLeft = parseFloat(computed.marginLeft) || 0, marginRight = parseFloat(computed.marginRight) || 0;
1247
+ let extent = height, clear = null;
1248
+ for (let pass = 0; pass < 3; pass++) {
1249
+ const next = geometry.boxClearance(own, outer.width, top, extent, floor);
1250
+ if (!next) break;
1251
+ if (!next.fits) return false;
1252
+ clear = {left: Math.max(clear?.left || 0, next.left), right: Math.max(clear?.right || 0, next.right)};
1253
+ style(element, {'margin-left': pixels(marginLeft + clear.left), 'margin-right': pixels(marginRight + clear.right)});
1254
+ const grown = box(element).height;
1255
+ if (Math.abs(grown - extent) < .5) break;
1256
+ extent = grown;
1257
+ }
1258
+ if (clear) return true;
1259
+ }
1238
1260
  if (element.tagName === 'DETAILS' && !element.open) {
1239
1261
  const summary = element.querySelector(':scope > summary');
1240
1262
  if (!summary) return false;
@@ -1246,21 +1268,26 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, pretext) {
1246
1268
  obstacles.map(obstacle => ({...obstacle, x: obstacle.x - bounds.left - inset + outer.left + parentInset})));
1247
1269
  }
1248
1270
  const computed = css(element);
1249
- top += (parseFloat(computed.paddingTop) || 0) + (parseFloat(computed.borderTopWidth) || 0);
1271
+ const inset = (parseFloat(computed.paddingTop) || 0) + (parseFloat(computed.borderTopWidth) || 0);
1272
+ top += inset;
1250
1273
  const bottom = top + height;
1251
1274
  const inside = obstacles.filter(obstacle => obstacle.y < bottom + 4096 && obstacle.y + obstacle.height > top &&
1252
1275
  obstacle.x < width && obstacle.x + obstacle.width > 0);
1253
1276
  if (!inside.length) return false;
1254
- // layout/model.mjs wrapShape; `geometry` is modules["layout/model.mjs"].
1277
+ // layout/model.mjs wrapShape; `geometry` is modules["layout/model.mjs"]. As the editor, the start side reaches from
1278
+ // the box's own start edge.
1279
+ const start = computed.direction === 'rtl' ? 'right' : 'left';
1280
+ const startInset = start === 'left' ? (parseFloat(computed.paddingLeft) || 0) + (parseFloat(computed.borderLeftWidth) || 0)
1281
+ : -((parseFloat(computed.paddingRight) || 0) + (parseFloat(computed.borderRightWidth) || 0));
1255
1282
  const shapeFor = side => {
1256
- const shape = geometry.wrapShape(side, inside, width, top);
1283
+ const shape = geometry.wrapShape(side, side !== start ? inside : inside.map(obstacle => ({...obstacle, x: obstacle.x + startInset})), width, top);
1257
1284
  if (!shape) return null;
1258
1285
  return {...shape, shape: 'polygon(' + shape.points.map(point => pixels(point[0]) + ' ' + pixels(point[1])).join(',') + ') border-box'};
1259
1286
  };
1260
1287
  const shapes = {left: shapeFor('left'), right: shapeFor('right')};
1261
1288
  const spent = (shapes.left?.boxWidth || 0) + (shapes.right?.boxWidth || 0);
1262
1289
  if (!spent || spent > width - metadata.wrapColumnFloor(parseFloat(css(element).fontSize) || 16)) return false;
1263
- const placed = [];
1290
+ const before = geometry.edgeLines(element), placed = [];
1264
1291
  for (const side of ['left', 'right']) {
1265
1292
  const shape = shapes[side];
1266
1293
  if (!shape) continue;
@@ -1269,25 +1296,39 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, pretext) {
1269
1296
  float.style.cssText = `float:${side};width:${pixels(shape.boxWidth)};height:${pixels(shape.boxHeight)};margin-top:${pixels(shape.startY)};shape-outside:${shape.shape};pointer-events:none`;
1270
1297
  element.prepend(float); floats.push(float); placed.push([float, shape]);
1271
1298
  }
1272
- // The block contains its floats and each float ends at the block's own content bottom, so
1273
- // successive blocks' floats never stack side by side (the same bound the editor applies).
1274
- style(element, {display: computed.display.includes('list-item') ? 'flow-root list-item' : 'flow-root'});
1299
+ // As the editor: each float ends at the block's own content bottom and the block keeps its own display, so its
1300
+ // margins collapse as they do with no picture. A float the content does not reach goes.
1275
1301
  const contentBottom = () => {
1276
1302
  let first = element.firstChild;
1277
1303
  while (first && placed.some(([float]) => float === first)) first = first.nextSibling;
1278
- if (!first || !element.lastChild) return box(element).height;
1304
+ if (!first || !element.lastChild) return box(element).height - inset;
1279
1305
  const range = doc.createRange(); range.setStartBefore(first); range.setEndAfter(element.lastChild);
1280
- return range.getBoundingClientRect().bottom - box(element).top;
1306
+ return range.getBoundingClientRect().bottom - box(element).top - inset;
1281
1307
  };
1282
1308
  for (let pass = 0; pass < 4; pass++) {
1283
1309
  const content = contentBottom();
1284
1310
  let changed = false;
1285
- for (const [float, shape] of placed) {
1286
- const bounded = Math.max(0, Math.min(shape.boxHeight, content - shape.startY));
1287
- if (Math.abs((parseFloat(float.style.height) || 0) - bounded) > 0.5) { float.style.height = pixels(bounded); changed = true; }
1311
+ for (const [float, shape] of [...placed]) {
1312
+ const bounded = Math.min(shape.boxHeight, content - shape.startY);
1313
+ if (bounded <= 0) { float.remove(); placed.splice(placed.findIndex(([value]) => value === float), 1); changed = true; }
1314
+ else if (Math.abs((parseFloat(float.style.height) || 0) - bounded) > 0.5) { float.style.height = pixels(bounded); changed = true; }
1288
1315
  }
1289
1316
  if (!changed) break;
1290
1317
  }
1318
+ // As the editor: a list item's marker and a quote's bar move with the words the floats moved.
1319
+ const {markers, bars, steps: breaks} = geometry.edgeMarks(element, before);
1320
+ for (const {item, shift} of markers) style(item, {'--md-marker': 'calc(' + (item.style.getPropertyValue('--md-marker') || '0px') + ' + ' + pixels(shift) + ')'});
1321
+ for (const item of breaks) { item.setAttribute('data-rapier-wrap-step', ''); steps.push(item); }
1322
+ for (const {quote, runs} of bars) {
1323
+ const look = css(quote), edge = box(quote).top + (parseFloat(look.borderTopWidth) || 0), barWidth = parseFloat(look.borderInlineStartWidth), color = look.borderInlineStartColor;
1324
+ style(quote, {'border-inline-start-color': 'transparent', ...(look.position === 'static' ? {position: 'relative'} : {})});
1325
+ for (const run of runs) {
1326
+ const bar = doc.createElement('span');
1327
+ bar.setAttribute('aria-hidden', 'true');
1328
+ bar.style.cssText = `position:absolute;inset-inline-start:${pixels(run.shift - barWidth)};top:${pixels(run.top - edge)};width:${pixels(barWidth)};height:${pixels(run.bottom - run.top)};background:${color};pointer-events:none`;
1329
+ quote.prepend(bar); floats.push(bar);
1330
+ }
1331
+ }
1291
1332
  return true;
1292
1333
  }
1293
1334
 
@@ -1322,13 +1363,11 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, pretext) {
1322
1363
  syncInlineRotatedPictures(positionedImages);
1323
1364
  for (const element of children) {
1324
1365
  if (wrapped.has(element)) continue;
1325
- // `let`: a pushed picture moves its owner and every later element.
1326
- let bounds = box(element);
1366
+ const bounds = box(element);
1327
1367
  if (!bounds.width || !bounds.height) continue;
1328
1368
  const computed = css(element), inset = (parseFloat(computed.paddingLeft) || 0) + (parseFloat(computed.borderLeftWidth) || 0);
1329
1369
  const width = bounds.width - inset - (parseFloat(computed.paddingRight) || 0) - (parseFloat(computed.borderRightWidth) || 0);
1330
1370
 
1331
- let ownerLed = false;
1332
1371
  for (const {element: source, image, layout, width: imageWidth} of anchors.get(element) || []) {
1333
1372
  // A picture sized in lines (`lines`) reads its anchor's line and stands on the first line's cap height, as the live editor.
1334
1373
  const lines = layout.lines != null ? geometry.lineMetrics(element) : null;
@@ -1347,19 +1386,12 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, pretext) {
1347
1386
  // The standoff and the room between pictures, in the reference scale's pixels, as the live editor.
1348
1387
  const scale = geometry.frameScale(image), room = 10 * scale;
1349
1388
  if (!outOfFlow(layout)) {
1389
+ // A picture an earlier one stands in moves down alone; its owner keeps its place, as in the editor.
1350
1390
  for (const obstacle of [...obstacles].sort((left, right) => left.y - right.y)) {
1351
1391
  if (obstacle.x < left + rectangle.width + room && obstacle.x + obstacle.width > left - room &&
1352
1392
  obstacle.y + obstacle.height > y - room && obstacle.y < y + rectangle.height + room)
1353
1393
  y = obstacle.y + obstacle.height + room;
1354
1394
  }
1355
- // A picture pushed down by an earlier one takes its owner with it. A deliberate `y` stays; only the collision push moves the paragraph.
1356
- const ownerTop = bounds.top - origin + topInset + geometry.linesTop(layout, lines) + Math.min(layout.y || 0, height / em) * em;
1357
- // Only the first picture of an owner takes it down.
1358
- if (!ownerLed && y > ownerTop + 0.5) {
1359
- clearTo(element, (bounds.top - origin) + (y - ownerTop), origin, source);
1360
- bounds = box(element);
1361
- }
1362
- ownerLed = true;
1363
1395
  obstacles.push(...geometry.linesSlices(geometry.pictureSlices(profile(image, layout), left, y, rectangle.width, rectangle.height, scale), layout, lines, y, rectangle.height));
1364
1396
  }
1365
1397
  placed.push({source, image, rectangle, left, y, wrap: layout.wrap,
@@ -1371,7 +1403,8 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, pretext) {
1371
1403
  const own = active.map(obstacle => ({...obstacle, x: obstacle.x - bounds.left - inset}));
1372
1404
  const prepared = prepare(element);
1373
1405
  if (!(prepared && width > 0 && project(prepared, width, top, own)) && !(width > 0 && floatAround(element, width, bounds.height, top, own))) {
1374
- const overlapping = active.filter(obstacle => obstacle.y < top + bounds.height);
1406
+ // As the editor: only an outline that meets the block's box moves it.
1407
+ const overlapping = active.filter(obstacle => obstacle.y < top + bounds.height && obstacle.x < bounds.right && obstacle.x + obstacle.width > bounds.left);
1375
1408
  if (overlapping.length) clearTo(element, Math.max(...overlapping.map(obstacle => obstacle.y + obstacle.height)), origin);
1376
1409
  }
1377
1410
  }
@@ -373,8 +373,25 @@ export function slotsForBand(width, obstacles, top, height, minWidth = 40) {
373
373
  return slots;
374
374
  }
375
375
 
376
- export function flowLines(flow, width, top, obstacles, lineHeight, minWidth, direction = 'ltr', balance = 0) {
377
- return linePlan(flow, width, top, obstacles, lineHeight, minWidth, direction, balance, slotsForBand, layoutNextRichInlineLineRange, materializeRichInlineLineRange);
376
+ // A picture moves a block's words only where its outline (with its standoff) meets them. The block is laid as it lies with no
377
+ // picture beside it, balanced and aligned in the whole column; when no line of words there meets an obstacle, that is its
378
+ // layout. Otherwise its lines flow around the outline. A block set from the side its lines start on, and not balanced, lies
379
+ // the same both ways whenever the outline misses its words, so it is laid once.
380
+ export function flowLines(flow, width, top, obstacles, lineHeight, minWidth, direction = 'ltr', balance = 0, align = 'start') {
381
+ const plan = linePlan(flow, width, top, obstacles, lineHeight, minWidth, direction, balance, slotsForBand, layoutNextRichInlineLineRange, materializeRichInlineLineRange);
382
+ const side = align === 'center' ? 'center' : align === 'left' || align === 'right' ? align
383
+ : (align === 'end') === (direction === 'rtl') ? 'left' : 'right';
384
+ if (!plan || !obstacles?.length || !balance && side === (direction === 'rtl' ? 'right' : 'left')) return plan;
385
+ const free = linePlan(flow, width, top, [], lineHeight, minWidth, direction, balance, slotsForBand, layoutNextRichInlineLineRange, materializeRichInlineLineRange);
386
+ if (!free) return plan;
387
+ const meets = free.lines.some(line => {
388
+ const words = line.fragments.reduce((sum, fragment) => sum + (fragment.gapBefore || 0) + (fragment.occupiedWidth || 0), 0);
389
+ const left = side === 'left' ? 0 : side === 'right' ? width - words : (width - words) / 2;
390
+ const lineTop = top + line.y;
391
+ return obstacles.some(obstacle => obstacle.width > 0 && obstacle.height > 0 &&
392
+ obstacle.x < left + words && obstacle.x + obstacle.width > left && obstacle.y < lineTop + lineHeight && obstacle.y + obstacle.height > lineTop);
393
+ });
394
+ return meets ? plan : free;
378
395
  }
379
396
 
380
397
  // ---- The one wrap shape (live: globalThis.RapierImageLayout; export: modules["layout/model.mjs"]) ----
@@ -411,6 +428,106 @@ export function wrapShape(side, obstacles, width, top) {
411
428
  return {boxWidth, boxHeight, startY, points};
412
429
  }
413
430
 
431
+ // ---- What a block draws at its edge moves with its words (live and export alike) ----
432
+ // A bordered box (a disclosure) cannot follow an outline line by line: it stands clear of it. `obstacles` in the box's
433
+ // border-box coordinates, its band [top, top + height]: the insets that end the box where the outline begins on each
434
+ // side, `fits` false when they leave less than `floor`; null when the outline does not meet the box.
435
+ export function boxClearance(obstacles, width, top, height, floor) {
436
+ let left = 0, right = 0;
437
+ for (const row of obstacles) {
438
+ if (row.y >= top + height || row.y + row.height <= top || row.x >= width || row.x + row.width <= 0) continue;
439
+ if (row.x > width - (row.x + row.width)) right = Math.max(right, width - row.x);
440
+ else left = Math.max(left, row.x + row.width);
441
+ }
442
+ return left || right ? {left, right, fits: width - left - right >= floor} : null;
443
+ }
444
+
445
+ // Where an element's content begins at the inline start (a right edge counts negative in a right-to-left block).
446
+ function contentStart(element) {
447
+ const style = element.ownerDocument.defaultView.getComputedStyle(element), box = element.getBoundingClientRect();
448
+ return style.direction === 'rtl' ? -(box.right - (parseFloat(style.paddingRight) || 0) - (parseFloat(style.borderRightWidth) || 0))
449
+ : box.left + (parseFloat(style.paddingLeft) || 0) + (parseFloat(style.borderLeftWidth) || 0);
450
+ }
451
+
452
+ // The lines of words in `root` (text outside `skip`), each with its block, where it starts and where its block's content starts.
453
+ export function edgeLines(root, skip = () => false) {
454
+ const doc = root.ownerDocument, view = doc.defaultView, lines = [], starts = new Map();
455
+ const walker = doc.createTreeWalker(root, 4);
456
+ for (let node; (node = walker.nextNode());) {
457
+ if (!node.data.trim() || skip(node)) continue;
458
+ let block = node.parentElement;
459
+ while (block !== root && view.getComputedStyle(block).display.startsWith('inline')) block = block.parentElement;
460
+ if (!starts.has(block)) starts.set(block, contentStart(block));
461
+ const rtl = view.getComputedStyle(block).direction === 'rtl', range = doc.createRange();
462
+ range.selectNodeContents(node);
463
+ for (const box of range.getClientRects()) {
464
+ if (!(box.width > 0)) continue;
465
+ const line = lines.find(row => row.block === block && Math.abs(row.top - box.top) < 2);
466
+ const start = rtl ? -box.right : box.left;
467
+ if (line) { line.start = Math.min(line.start, start); line.bottom = Math.max(line.bottom, box.bottom); }
468
+ else lines.push({block, top: box.top, bottom: box.bottom, start, content: starts.get(block)});
469
+ }
470
+ }
471
+ return lines;
472
+ }
473
+
474
+ // After a picture's floats settle: how far each line moved from where its block's lines began without them (`before`,
475
+ // edgeLines read first). A list item's marker moves as far as its first line; a quote's bar moves to stand beside its
476
+ // moved lines as it stood beside them before, in runs of one shift from the quote's top to its bottom.
477
+ export function edgeMarks(root, before, skip = () => false) {
478
+ const view = root.ownerDocument.defaultView, home = new Map();
479
+ const byBlock = rows => {
480
+ const blocks = new Map();
481
+ for (const row of rows) { if (!blocks.has(row.block)) blocks.set(row.block, []); blocks.get(row.block).push(row); }
482
+ for (const list of blocks.values()) list.sort((a, b) => a.top - b.top);
483
+ return blocks;
484
+ };
485
+ for (const [block, rows] of byBlock(before)) {
486
+ const content = rows[0].content;
487
+ home.set(block, {content, first: rows[0].start, rest: rows.length > 1 ? Math.min(...rows.slice(1).map(row => row.start)) : content});
488
+ }
489
+ const lines = [];
490
+ for (const [block, rows] of byBlock(edgeLines(root, skip))) rows.forEach((row, index) => {
491
+ const natural = home.get(block);
492
+ if (natural) lines.push({...row, content: natural.content, moved: Math.max(0, row.start - (index ? natural.rest : natural.first))});
493
+ });
494
+ const within = selector => [...(root.matches(selector) ? [root] : []), ...root.querySelectorAll(selector)];
495
+ const shiftOf = item => lines.filter(line => line.block.closest('li') === item).sort((a, b) => a.top - b.top)[0]?.moved || 0;
496
+ const markers = [];
497
+ for (const item of within('li')) {
498
+ if (item.classList.contains('task-list-item')) continue;
499
+ const shift = shiftOf(item);
500
+ if (shift > .5) markers.push({item, shift});
501
+ }
502
+ // A numbered item's dotted line runs to the next circle: where the two circles moved apart it is not drawn (`steps`).
503
+ const steps = [];
504
+ for (const list of within('ol')) {
505
+ const items = [...list.children].filter(child => child.tagName === 'LI');
506
+ items.forEach((item, index) => {
507
+ const next = items[index + 1];
508
+ if (next ? Math.abs(shiftOf(item) - shiftOf(next)) > .5 : list.hasAttribute('data-rapier-list-continues') && shiftOf(item) > .5) steps.push(item);
509
+ });
510
+ }
511
+ const bars = [];
512
+ for (const quote of within('blockquote')) {
513
+ if (!(parseFloat(view.getComputedStyle(quote).borderInlineStartWidth) > 0)) continue;
514
+ const start = contentStart(quote);
515
+ const own = lines.filter(line => quote.contains(line.block)).sort((a, b) => a.top - b.top)
516
+ .map(line => ({...line, shift: line.moved > .5 ? line.moved + line.content - start : 0}));
517
+ if (!own.some(line => line.shift > .5)) continue;
518
+ const box = quote.getBoundingClientRect(), runs = [];
519
+ for (const line of own) {
520
+ const run = runs.at(-1);
521
+ if (run && Math.abs(run.shift - line.shift) <= .5) { run.bottom = line.bottom; continue; }
522
+ if (run) run.end = (run.bottom + line.top) / 2;
523
+ runs.push({shift: line.shift, top: run ? run.end : box.top, bottom: line.bottom});
524
+ }
525
+ runs.at(-1).end = box.bottom;
526
+ bars.push({quote, runs: runs.map(run => ({shift: run.shift, top: run.top, bottom: run.end}))});
527
+ }
528
+ return {markers, bars, steps};
529
+ }
530
+
414
531
  // ---- The one "paragraph holds only the picture": one meaningful child, the image or an <a> wrapping only it ----
415
532
  export function imageOnly(paragraph, image) {
416
533
  const meaningful = node => [...node.childNodes].filter(child => child.nodeType !== 8 &&
package/package.json CHANGED
@@ -1,23 +1,24 @@
1
1
  {
2
2
  "name": "rapier-markdown-kit",
3
- "version": "1.1.87",
4
- "description": "Read, render, write and style Self-contained Markdown, and carry its optional edit ledger: the shared HTML writer, semantic renderer, stylesheet, layout, marks, pictures, Will/1, replay, authorship and offline merge. MIT, no npm dependencies.",
3
+ "version": "1.1.89",
4
+ "description": "Read, render and write Self-contained Markdown: one .md file that carries its pictures, drawings and layout, and that any Markdown app can read. HTML renderer, stylesheet, text layout, Will/1 agent permissions, and an edit history with authorship and offline merge. MIT, no dependencies.",
5
5
  "keywords": [
6
6
  "markdown",
7
7
  "self-contained",
8
8
  "commonmark",
9
9
  "gfm",
10
+ "renderer",
10
11
  "stylesheet",
11
12
  "css",
12
- "layout",
13
13
  "text-layout",
14
- "text-wrap",
15
14
  "line-breaking",
16
15
  "images",
16
+ "svg",
17
17
  "document",
18
18
  "will",
19
- "agent",
20
- "conformance",
19
+ "ai-agents",
20
+ "authorship",
21
+ "merge",
21
22
  "rapier"
22
23
  ],
23
24
  "author": "Jack Skipworth",
package/style.css CHANGED
@@ -234,7 +234,7 @@
234
234
  /* The line model (the way a Word document reads). One line, --md-line, is the body's line box: 1.75rem, 1.6 lines
235
235
  of the 1.1rem body, a whole 28 px at the browser's 16 px root so baselines land on pixels. Every block ends one
236
236
  line below its last line; a heading ends half a line below. A heading's own box is the whole or half lines its
237
- size fills: h1 at 2.43 x the body on two lines (medium weight, lighter than the bold smaller headings), h2 and h3
237
+ size fills: h1 at 2.6 x the body on two lines (medium weight, lighter than the bold smaller headings), h2 and h3
238
238
  on one and a half, h4 to h6 on one. Nothing sets a top
239
239
  margin, so a plain page (sibling margins collapsing) and the editor (a wrapper per block) space alike, and the
240
240
  space before any block is the line its predecessor leaves. */
@@ -246,7 +246,7 @@
246
246
  .md-render h1,.md-render h2,.md-render h3,.md-render h4,.md-render h5,.md-render h6{
247
247
  font-family:var(--md-font-sans);letter-spacing:-0.01em;
248
248
  line-height:var(--md-line);margin:0 0 calc(var(--md-line) / 2);color:var(--md-heading-color);text-wrap:balance}
249
- .md-render h1{font-size:calc(var(--md-text-body) * 2.43);line-height:calc(var(--md-line) * 2);font-weight:500}
249
+ .md-render h1{font-size:calc(var(--md-text-body) * 2.6);line-height:calc(var(--md-line) * 2);font-weight:500}
250
250
  .md-render h2{font-size:calc(var(--md-text-body) * 1.9);line-height:calc(var(--md-line) * 1.5);font-weight:600}
251
251
  .md-render h3{font-size:calc(var(--md-text-body) * 1.5);line-height:calc(var(--md-line) * 1.5);font-weight:600}
252
252
  .md-render h4{font-size:calc(var(--md-text-body) * 1.25);font-weight:500}
@@ -377,6 +377,8 @@
377
377
  _rapierMarkListContinuations marks them): the heading stands on the margin the item text stands on, and the dotted
378
378
  line runs on, out of the first part's last item, down the heading's own height, to the next circle. */
379
379
  .md-render ol[data-rapier-list-continues]>li:last-child::after{bottom:calc(-1 * var(--md-line))}
380
+ /* A wrapped picture set the next circle aside: the dotted line ends with this item, as at a list's last. */
381
+ .md-render ol>li[data-rapier-wrap-step]::after{content:none!important}
380
382
  .md-render :is(h1,h2,h3,h4,h5,h6)[data-rapier-list-through]{position:relative;padding-inline-start:calc(2.75 * var(--md-unit))}
381
383
  .md-render :is(h1,h2,h3,h4,h5,h6)[data-rapier-list-through]::before{
382
384
  content:'';