@vectojs/markdown 0.12.0 → 0.14.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/dist/Markdown.d.ts +99 -0
- package/dist/blockAffordances.d.ts +179 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +528 -69
- package/dist/index.mjs +498 -41
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -423,7 +423,259 @@ function createStreamController(host, options = {}) {
|
|
|
423
423
|
}
|
|
424
424
|
|
|
425
425
|
// src/Markdown.ts
|
|
426
|
-
import {
|
|
426
|
+
import {
|
|
427
|
+
measureText as measureText2,
|
|
428
|
+
RichText,
|
|
429
|
+
Stack,
|
|
430
|
+
Table,
|
|
431
|
+
Text,
|
|
432
|
+
Image,
|
|
433
|
+
UIComponent as UIComponent2
|
|
434
|
+
} from "@vectojs/ui";
|
|
435
|
+
|
|
436
|
+
// src/blockAffordances.ts
|
|
437
|
+
import { Button, measureText, UIComponent } from "@vectojs/ui";
|
|
438
|
+
var LANGUAGE_EXTENSIONS = {
|
|
439
|
+
bash: "sh",
|
|
440
|
+
c: "c",
|
|
441
|
+
cpp: "cpp",
|
|
442
|
+
cs: "cs",
|
|
443
|
+
css: "css",
|
|
444
|
+
diff: "diff",
|
|
445
|
+
dockerfile: "dockerfile",
|
|
446
|
+
go: "go",
|
|
447
|
+
graphql: "graphql",
|
|
448
|
+
haskell: "hs",
|
|
449
|
+
html: "html",
|
|
450
|
+
java: "java",
|
|
451
|
+
javascript: "js",
|
|
452
|
+
js: "js",
|
|
453
|
+
json: "json",
|
|
454
|
+
jsonc: "jsonc",
|
|
455
|
+
jsx: "jsx",
|
|
456
|
+
kotlin: "kt",
|
|
457
|
+
latex: "tex",
|
|
458
|
+
lua: "lua",
|
|
459
|
+
make: "mk",
|
|
460
|
+
markdown: "md",
|
|
461
|
+
md: "md",
|
|
462
|
+
nix: "nix",
|
|
463
|
+
php: "php",
|
|
464
|
+
python: "py",
|
|
465
|
+
py: "py",
|
|
466
|
+
ruby: "rb",
|
|
467
|
+
rust: "rs",
|
|
468
|
+
rs: "rs",
|
|
469
|
+
scss: "scss",
|
|
470
|
+
sh: "sh",
|
|
471
|
+
shell: "sh",
|
|
472
|
+
sql: "sql",
|
|
473
|
+
svelte: "svelte",
|
|
474
|
+
swift: "swift",
|
|
475
|
+
tex: "tex",
|
|
476
|
+
toml: "toml",
|
|
477
|
+
ts: "ts",
|
|
478
|
+
tsx: "tsx",
|
|
479
|
+
typescript: "ts",
|
|
480
|
+
vue: "vue",
|
|
481
|
+
xml: "xml",
|
|
482
|
+
yaml: "yaml",
|
|
483
|
+
yml: "yaml",
|
|
484
|
+
zig: "zig",
|
|
485
|
+
zsh: "sh"
|
|
486
|
+
};
|
|
487
|
+
function extensionForLanguage(lang) {
|
|
488
|
+
const first = lang.trim().toLowerCase().split(/[\s:,{]/)[0] ?? "";
|
|
489
|
+
return LANGUAGE_EXTENSIONS[first] ?? "txt";
|
|
490
|
+
}
|
|
491
|
+
function mimeForLanguage(lang) {
|
|
492
|
+
const ext = extensionForLanguage(lang);
|
|
493
|
+
if (ext === "json" || ext === "jsonc") return "application/json";
|
|
494
|
+
if (ext === "html") return "text/html";
|
|
495
|
+
if (ext === "css") return "text/css";
|
|
496
|
+
if (ext === "xml" || ext === "svelte" || ext === "vue") return "text/plain";
|
|
497
|
+
return "text/plain";
|
|
498
|
+
}
|
|
499
|
+
function escapeCsvField(value) {
|
|
500
|
+
let needsQuoting = false;
|
|
501
|
+
let hasQuote = false;
|
|
502
|
+
for (const char of value) {
|
|
503
|
+
if (char === '"') {
|
|
504
|
+
hasQuote = true;
|
|
505
|
+
needsQuoting = true;
|
|
506
|
+
break;
|
|
507
|
+
}
|
|
508
|
+
if (char === "," || char === "\n" || char === "\r") needsQuoting = true;
|
|
509
|
+
}
|
|
510
|
+
if (!needsQuoting) return value;
|
|
511
|
+
return hasQuote ? `"${value.replace(/"/g, '""')}"` : `"${value}"`;
|
|
512
|
+
}
|
|
513
|
+
function escapeMarkdownTableCell(cell) {
|
|
514
|
+
let needsEscaping = false;
|
|
515
|
+
for (const char of cell) {
|
|
516
|
+
if (char === "\\" || char === "|") {
|
|
517
|
+
needsEscaping = true;
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (!needsEscaping) return cell;
|
|
522
|
+
return cell.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
|
|
523
|
+
}
|
|
524
|
+
function tableToCsv(table) {
|
|
525
|
+
const lines = [table.headers.map(escapeCsvField).join(",")];
|
|
526
|
+
for (const row of table.rows) lines.push(row.map(escapeCsvField).join(","));
|
|
527
|
+
return `\uFEFF${lines.join("\r\n")}`;
|
|
528
|
+
}
|
|
529
|
+
function tableToMarkdown(table) {
|
|
530
|
+
const header = `| ${table.headers.map(escapeMarkdownTableCell).join(" | ")} |`;
|
|
531
|
+
const divider = `| ${table.headers.map((_cell, index) => {
|
|
532
|
+
switch (table.align[index]) {
|
|
533
|
+
case "left":
|
|
534
|
+
return ":---";
|
|
535
|
+
case "center":
|
|
536
|
+
return ":---:";
|
|
537
|
+
case "right":
|
|
538
|
+
return "---:";
|
|
539
|
+
default:
|
|
540
|
+
return "---";
|
|
541
|
+
}
|
|
542
|
+
}).join(" | ")} |`;
|
|
543
|
+
const body = table.rows.map(
|
|
544
|
+
(row) => `| ${table.headers.map((_cell, index) => escapeMarkdownTableCell(row[index] ?? "")).join(" | ")} |`
|
|
545
|
+
);
|
|
546
|
+
return [header, divider, ...body].join("\n");
|
|
547
|
+
}
|
|
548
|
+
function defaultWriteClipboard(text) {
|
|
549
|
+
const clipboard = globalThis.navigator?.clipboard;
|
|
550
|
+
clipboard?.writeText?.(text);
|
|
551
|
+
}
|
|
552
|
+
function defaultSaveFile(filename, content, mimeType) {
|
|
553
|
+
const doc = globalThis.document;
|
|
554
|
+
if (!doc?.body) return;
|
|
555
|
+
const blob = new Blob([content], { type: mimeType });
|
|
556
|
+
const url = URL.createObjectURL(blob);
|
|
557
|
+
const anchor = doc.createElement("a");
|
|
558
|
+
anchor.href = url;
|
|
559
|
+
anchor.download = filename;
|
|
560
|
+
doc.body.appendChild(anchor);
|
|
561
|
+
anchor.click();
|
|
562
|
+
doc.body.removeChild(anchor);
|
|
563
|
+
URL.revokeObjectURL(url);
|
|
564
|
+
}
|
|
565
|
+
var BlockAffordanceButton = class _BlockAffordanceButton extends Button {
|
|
566
|
+
constructor(label, successLabel, act, opts = {}) {
|
|
567
|
+
super(label, { ...opts, onClick: () => this.run() });
|
|
568
|
+
this.act = act;
|
|
569
|
+
this.restingLabel = label;
|
|
570
|
+
this.successLabel = successLabel;
|
|
571
|
+
this.width = Math.max(this.width, measureText(successLabel, this.font) + 24);
|
|
572
|
+
}
|
|
573
|
+
act;
|
|
574
|
+
/** How long the confirmation label stays up, in ms. */
|
|
575
|
+
static FEEDBACK_MS = 1600;
|
|
576
|
+
restingLabel;
|
|
577
|
+
successLabel;
|
|
578
|
+
feedbackTimer;
|
|
579
|
+
/**
|
|
580
|
+
* Runs the action, then shows the confirmation.
|
|
581
|
+
*
|
|
582
|
+
* The action runs first and a throw propagates: a clipboard write the browser
|
|
583
|
+
* rejected must not be reported as a success.
|
|
584
|
+
*/
|
|
585
|
+
run() {
|
|
586
|
+
this.act();
|
|
587
|
+
this.setTransientLabel(this.successLabel);
|
|
588
|
+
if (this.feedbackTimer !== void 0) clearTimeout(this.feedbackTimer);
|
|
589
|
+
this.feedbackTimer = setTimeout(() => {
|
|
590
|
+
this.setTransientLabel(this.restingLabel);
|
|
591
|
+
this.feedbackTimer = void 0;
|
|
592
|
+
}, _BlockAffordanceButton.FEEDBACK_MS);
|
|
593
|
+
}
|
|
594
|
+
setTransientLabel(label) {
|
|
595
|
+
this.label = label;
|
|
596
|
+
this.textWidth = measureText(label, this.font);
|
|
597
|
+
this.scene?.markDirty();
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* The label a reader hears is the one they see, transient confirmation
|
|
601
|
+
* included, so an AT user gets the same feedback a sighted user does.
|
|
602
|
+
*/
|
|
603
|
+
getA11yAttributes() {
|
|
604
|
+
return { ...super.getA11yAttributes(), label: this.label };
|
|
605
|
+
}
|
|
606
|
+
/** Clears the pending revert so a destroyed block leaves no timer behind. */
|
|
607
|
+
destroy() {
|
|
608
|
+
if (this.feedbackTimer !== void 0) {
|
|
609
|
+
clearTimeout(this.feedbackTimer);
|
|
610
|
+
this.feedbackTimer = void 0;
|
|
611
|
+
}
|
|
612
|
+
super.destroy();
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
var BlockWithAffordances = class _BlockWithAffordances extends UIComponent {
|
|
616
|
+
constructor(block, controls) {
|
|
617
|
+
super();
|
|
618
|
+
this.block = block;
|
|
619
|
+
this.controls = controls;
|
|
620
|
+
this.add(block);
|
|
621
|
+
for (const control of controls) this.add(control);
|
|
622
|
+
this.layoutAffordances();
|
|
623
|
+
}
|
|
624
|
+
block;
|
|
625
|
+
controls;
|
|
626
|
+
/** Gap between the block's edges and the controls, in px. */
|
|
627
|
+
static INSET = 8;
|
|
628
|
+
/** Gap between adjacent controls, in px. */
|
|
629
|
+
static GAP = 6;
|
|
630
|
+
/**
|
|
631
|
+
* Places the controls right-aligned along the block's top edge.
|
|
632
|
+
*
|
|
633
|
+
* Laid out right-to-left from the block's right edge so the first control in
|
|
634
|
+
* the list ends up leftmost, which keeps DOM order (and therefore tab order and
|
|
635
|
+
* the a11y reading order) matching the visual order.
|
|
636
|
+
*/
|
|
637
|
+
layoutAffordances() {
|
|
638
|
+
this.width = this.block.width;
|
|
639
|
+
this.height = this.block.height;
|
|
640
|
+
let right = this.block.width - _BlockWithAffordances.INSET;
|
|
641
|
+
for (let i = this.controls.length - 1; i >= 0; i--) {
|
|
642
|
+
const control = this.controls[i];
|
|
643
|
+
control.x = right - control.width;
|
|
644
|
+
control.y = _BlockWithAffordances.INSET;
|
|
645
|
+
right = control.x - _BlockWithAffordances.GAP;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Re-places the controls after the block's own box changed.
|
|
650
|
+
*
|
|
651
|
+
* Called by the owner when a block is resized or its content grew; the controls
|
|
652
|
+
* are anchored to the right edge, so a width change moves them.
|
|
653
|
+
*/
|
|
654
|
+
refreshAffordances() {
|
|
655
|
+
this.layoutAffordances();
|
|
656
|
+
this.scene?.markDirty();
|
|
657
|
+
}
|
|
658
|
+
/** The wrapper is a pass-through: its size is the block's size. */
|
|
659
|
+
getLayoutControlledProperties() {
|
|
660
|
+
return ["x", "y"];
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Projected as a group so assistive technology reports one labelled region
|
|
664
|
+
* containing the block and its controls, rather than two unrelated siblings.
|
|
665
|
+
*/
|
|
666
|
+
getA11yAttributes() {
|
|
667
|
+
return { role: "group", pointerEvents: "none" };
|
|
668
|
+
}
|
|
669
|
+
render() {
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
function tableContentOf(token) {
|
|
673
|
+
return {
|
|
674
|
+
headers: token.header.map((cell) => cell.text),
|
|
675
|
+
rows: token.rows.map((row) => row.map((cell) => cell.text)),
|
|
676
|
+
align: token.align
|
|
677
|
+
};
|
|
678
|
+
}
|
|
427
679
|
|
|
428
680
|
// src/frontMatter.ts
|
|
429
681
|
var OPEN_RE = /^---[ \t]*\r?\n/;
|
|
@@ -668,7 +920,59 @@ function isFenceClosed(raw) {
|
|
|
668
920
|
return false;
|
|
669
921
|
}
|
|
670
922
|
function paragraphHasImage(token) {
|
|
671
|
-
return token.tokens
|
|
923
|
+
return containsImage(token.tokens);
|
|
924
|
+
}
|
|
925
|
+
function containsImage(tokens) {
|
|
926
|
+
if (!tokens) return false;
|
|
927
|
+
for (const token of tokens) {
|
|
928
|
+
if (token.type === "image") return true;
|
|
929
|
+
if (containsImage(token.tokens)) return true;
|
|
930
|
+
}
|
|
931
|
+
return false;
|
|
932
|
+
}
|
|
933
|
+
function imagesOf(tokens) {
|
|
934
|
+
const images = [];
|
|
935
|
+
for (const token of tokens ?? []) {
|
|
936
|
+
if (token.type === "image") {
|
|
937
|
+
images.push(token);
|
|
938
|
+
continue;
|
|
939
|
+
}
|
|
940
|
+
images.push(...imagesOf(token.tokens));
|
|
941
|
+
}
|
|
942
|
+
return images;
|
|
943
|
+
}
|
|
944
|
+
function stripImages(token) {
|
|
945
|
+
const children = token.tokens;
|
|
946
|
+
if (!children) return token;
|
|
947
|
+
const kept = [];
|
|
948
|
+
for (const child of children) {
|
|
949
|
+
if (child.type === "image") continue;
|
|
950
|
+
const grandchildren = child.tokens;
|
|
951
|
+
if (grandchildren && containsImage(grandchildren)) {
|
|
952
|
+
const stripped = stripImages(child);
|
|
953
|
+
const remaining = stripped.tokens;
|
|
954
|
+
if (remaining && remaining.length > 0) kept.push(stripped);
|
|
955
|
+
continue;
|
|
956
|
+
}
|
|
957
|
+
kept.push(child);
|
|
958
|
+
}
|
|
959
|
+
return { ...token, tokens: kept };
|
|
960
|
+
}
|
|
961
|
+
function liftNestedImages(tokens) {
|
|
962
|
+
const lifted = [];
|
|
963
|
+
for (const token of tokens) {
|
|
964
|
+
if (token.type === "image") {
|
|
965
|
+
lifted.push(token);
|
|
966
|
+
continue;
|
|
967
|
+
}
|
|
968
|
+
const children = token.tokens;
|
|
969
|
+
if (children && containsImage(children)) {
|
|
970
|
+
lifted.push(...liftNestedImages(children));
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
lifted.push(token);
|
|
974
|
+
}
|
|
975
|
+
return lifted;
|
|
672
976
|
}
|
|
673
977
|
function lastIndexOfImage(tokens) {
|
|
674
978
|
for (let i = tokens.length - 1; i >= 0; i--) {
|
|
@@ -679,7 +983,7 @@ function lastIndexOfImage(tokens) {
|
|
|
679
983
|
function expectedImageParagraphChildren(tokens) {
|
|
680
984
|
let children = 0;
|
|
681
985
|
let inTextRun = false;
|
|
682
|
-
for (const token of tokens) {
|
|
986
|
+
for (const token of liftNestedImages(tokens)) {
|
|
683
987
|
if (token.type === "image") {
|
|
684
988
|
children++;
|
|
685
989
|
inTextRun = false;
|
|
@@ -839,6 +1143,33 @@ var MarkdownContainer = class extends Entity {
|
|
|
839
1143
|
render(_r) {
|
|
840
1144
|
}
|
|
841
1145
|
};
|
|
1146
|
+
var MathBlock = class extends MarkdownContainer {
|
|
1147
|
+
/**
|
|
1148
|
+
* The TeX source, exactly as written between the delimiters.
|
|
1149
|
+
*
|
|
1150
|
+
* Also the projected text and the accessible name, so this is the one string a
|
|
1151
|
+
* reader can find, select, and copy.
|
|
1152
|
+
*/
|
|
1153
|
+
formula;
|
|
1154
|
+
/** The `data:image/svg+xml` URI of the typeset glyphs. */
|
|
1155
|
+
svgUri;
|
|
1156
|
+
constructor(formula, svgUri) {
|
|
1157
|
+
super();
|
|
1158
|
+
this.formula = formula;
|
|
1159
|
+
this.svgUri = svgUri;
|
|
1160
|
+
}
|
|
1161
|
+
getDevtoolsDescriptor() {
|
|
1162
|
+
return {
|
|
1163
|
+
kind: "MathBlock",
|
|
1164
|
+
groups: [
|
|
1165
|
+
{
|
|
1166
|
+
label: "Math",
|
|
1167
|
+
fields: [{ label: "formula", value: this.formula, readOnly: true }]
|
|
1168
|
+
}
|
|
1169
|
+
]
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
};
|
|
842
1173
|
var KEYWORD_SETS = {
|
|
843
1174
|
js: /* @__PURE__ */ new Set([
|
|
844
1175
|
"const",
|
|
@@ -1088,7 +1419,7 @@ function highlightLine(line, lang, theme) {
|
|
|
1088
1419
|
flush(theme.codeColor);
|
|
1089
1420
|
return segments;
|
|
1090
1421
|
}
|
|
1091
|
-
var CodeBlock = class extends
|
|
1422
|
+
var CodeBlock = class extends UIComponent2 {
|
|
1092
1423
|
lines;
|
|
1093
1424
|
grid = null;
|
|
1094
1425
|
/** Raw (unhighlighted) lines of the last build, for prefix reuse in buildLines. */
|
|
@@ -1221,7 +1552,7 @@ var CodeBlock = class extends UIComponent {
|
|
|
1221
1552
|
this.height = this.pad * 2 + rawLines.length * this.lineH;
|
|
1222
1553
|
}
|
|
1223
1554
|
ensureGrid() {
|
|
1224
|
-
const cellWidth = this.cellWidth || Math.max(1,
|
|
1555
|
+
const cellWidth = this.cellWidth || Math.max(1, measureText2("M", this.codeFont));
|
|
1225
1556
|
if (!this.grid || this.grid.source !== this.source || this.grid.font !== this.codeFont || this.grid.cellWidth !== cellWidth) {
|
|
1226
1557
|
this.grid = prepareContentGrid(this.source, {
|
|
1227
1558
|
font: this.codeFont,
|
|
@@ -1499,12 +1830,24 @@ function renderInlineToRichText(tokens, fallbackText, font, color, maxWidth, the
|
|
|
1499
1830
|
onLinkClick
|
|
1500
1831
|
});
|
|
1501
1832
|
}
|
|
1502
|
-
var Markdown = class _Markdown extends
|
|
1833
|
+
var Markdown = class _Markdown extends UIComponent2 {
|
|
1503
1834
|
content;
|
|
1504
1835
|
maxWidth;
|
|
1505
1836
|
theme;
|
|
1506
1837
|
onLinkClick;
|
|
1507
1838
|
selectable;
|
|
1839
|
+
/**
|
|
1840
|
+
* Whether code blocks and tables carry copy / download controls.
|
|
1841
|
+
*
|
|
1842
|
+
* Read when a block entity is built, so it affects blocks rendered from here on
|
|
1843
|
+
* rather than retroactively; a document does not rebuild to gain or lose an
|
|
1844
|
+
* affordance.
|
|
1845
|
+
*/
|
|
1846
|
+
blockAffordances;
|
|
1847
|
+
/** Clipboard writer used by the copy controls. */
|
|
1848
|
+
writeClipboard;
|
|
1849
|
+
/** File saver used by the download controls. */
|
|
1850
|
+
saveFile;
|
|
1508
1851
|
activeBlockMetrics = null;
|
|
1509
1852
|
/**
|
|
1510
1853
|
* Called after a streamed append has re-laid-out the document.
|
|
@@ -1729,6 +2072,9 @@ var Markdown = class _Markdown extends UIComponent {
|
|
|
1729
2072
|
this.onLinkClick = opts.onLinkClick;
|
|
1730
2073
|
this.selectable = opts.selectable ?? true;
|
|
1731
2074
|
this._userTiming = opts.userTiming ?? false;
|
|
2075
|
+
this.blockAffordances = opts.blockAffordances ?? false;
|
|
2076
|
+
this.writeClipboard = opts.writeClipboard ?? defaultWriteClipboard;
|
|
2077
|
+
this.saveFile = opts.saveFile ?? defaultSaveFile;
|
|
1732
2078
|
this.content = new Stack({ direction: "vertical", gap: 16 });
|
|
1733
2079
|
this.add(this.content);
|
|
1734
2080
|
this.rawMarkdown = "";
|
|
@@ -2517,6 +2863,73 @@ var Markdown = class _Markdown extends UIComponent {
|
|
|
2517
2863
|
* policy for a zero-dimension source is a separate decision from notifying
|
|
2518
2864
|
* the scene, which is the actual defect here.
|
|
2519
2865
|
*/
|
|
2866
|
+
/**
|
|
2867
|
+
* Wraps a block in its copy / download controls, or returns it untouched.
|
|
2868
|
+
*
|
|
2869
|
+
* The controls are built lazily through `make` so a document with
|
|
2870
|
+
* `blockAffordances` off pays nothing — not the closures, not the measurement
|
|
2871
|
+
* `BlockAffordanceButton` does in its constructor.
|
|
2872
|
+
*/
|
|
2873
|
+
withBlockAffordances(block, make) {
|
|
2874
|
+
if (!this.blockAffordances) return block;
|
|
2875
|
+
const controls = make();
|
|
2876
|
+
return controls.length > 0 ? new BlockWithAffordances(block, controls) : block;
|
|
2877
|
+
}
|
|
2878
|
+
/** Copy and download controls for one fenced code block. */
|
|
2879
|
+
codeBlockAffordances(source, lang) {
|
|
2880
|
+
const opts = this.affordanceButtonOptions();
|
|
2881
|
+
return [
|
|
2882
|
+
new BlockAffordanceButton("Copy code", "Copied", () => this.writeClipboard(source), opts),
|
|
2883
|
+
new BlockAffordanceButton(
|
|
2884
|
+
"Download code",
|
|
2885
|
+
"Saved",
|
|
2886
|
+
() => this.saveFile(`code.${extensionForLanguage(lang)}`, source, mimeForLanguage(lang)),
|
|
2887
|
+
opts
|
|
2888
|
+
)
|
|
2889
|
+
];
|
|
2890
|
+
}
|
|
2891
|
+
/** Copy (as Markdown) and download (as CSV) controls for one table. */
|
|
2892
|
+
tableAffordances(tblToken) {
|
|
2893
|
+
const content = tableContentOf(tblToken);
|
|
2894
|
+
const opts = this.affordanceButtonOptions();
|
|
2895
|
+
return [
|
|
2896
|
+
// Markdown rather than CSV for the clipboard: the reader copied it out of a
|
|
2897
|
+
// Markdown document and the overwhelmingly likely destination is another
|
|
2898
|
+
// one. CSV is what the download is for, where a spreadsheet is the target.
|
|
2899
|
+
new BlockAffordanceButton(
|
|
2900
|
+
"Copy table",
|
|
2901
|
+
"Copied",
|
|
2902
|
+
() => this.writeClipboard(tableToMarkdown(content)),
|
|
2903
|
+
opts
|
|
2904
|
+
),
|
|
2905
|
+
new BlockAffordanceButton(
|
|
2906
|
+
"Download table",
|
|
2907
|
+
"Saved",
|
|
2908
|
+
() => this.saveFile("table.csv", tableToCsv(content), "text/csv;charset=utf-8"),
|
|
2909
|
+
opts
|
|
2910
|
+
)
|
|
2911
|
+
];
|
|
2912
|
+
}
|
|
2913
|
+
/**
|
|
2914
|
+
* Button styling for the affordances, derived from the document theme.
|
|
2915
|
+
*
|
|
2916
|
+
* Themed rather than hardcoded so a light-theme document does not get the dark
|
|
2917
|
+
* default palette. `focusColor` is set explicitly from the theme's accent
|
|
2918
|
+
* because `Button`'s default cyan is tuned for the dark palette and reads as
|
|
2919
|
+
* off-brand elsewhere — while a focus ring is the one affordance a keyboard
|
|
2920
|
+
* user cannot do without.
|
|
2921
|
+
*/
|
|
2922
|
+
affordanceButtonOptions() {
|
|
2923
|
+
return {
|
|
2924
|
+
font: `600 12px ${this.theme.bodyFont}`,
|
|
2925
|
+
padding: 6,
|
|
2926
|
+
radius: 6,
|
|
2927
|
+
bg: this.theme.codeBgColor,
|
|
2928
|
+
hoverBg: this.theme.tableHeaderBgColor,
|
|
2929
|
+
color: this.theme.textColor,
|
|
2930
|
+
focusColor: this.theme.codeColor
|
|
2931
|
+
};
|
|
2932
|
+
}
|
|
2520
2933
|
paragraphImage(imgToken, availableWidth) {
|
|
2521
2934
|
const initialWidth = Math.min(800, availableWidth);
|
|
2522
2935
|
const initialHeight = Math.round(initialWidth * 0.6);
|
|
@@ -2597,6 +3010,7 @@ var Markdown = class _Markdown extends UIComponent {
|
|
|
2597
3010
|
itemIsInlineOnly(item) {
|
|
2598
3011
|
const children = item.tokens;
|
|
2599
3012
|
if (!children || children.length === 0) return true;
|
|
3013
|
+
if (containsImage(children)) return false;
|
|
2600
3014
|
if (children.length === 1 && children[0].type === "paragraph") return true;
|
|
2601
3015
|
return children.every((child) => _Markdown.INLINE_ITEM_TOKENS.has(child.type));
|
|
2602
3016
|
}
|
|
@@ -2626,7 +3040,10 @@ var Markdown = class _Markdown extends UIComponent {
|
|
|
2626
3040
|
const children = item.tokens ?? [];
|
|
2627
3041
|
const stack = new Stack({ direction: "vertical", gap: 4 });
|
|
2628
3042
|
const first = children[0];
|
|
2629
|
-
const
|
|
3043
|
+
const firstIsInline = Boolean(first) && (first.type === "text" || first.type === "paragraph");
|
|
3044
|
+
const leadHasImage = firstIsInline && containsImage(first.tokens);
|
|
3045
|
+
const leadChildren = firstIsInline ? [leadHasImage ? stripImages(first) : first] : [];
|
|
3046
|
+
const leadImages = leadHasImage ? imagesOf(first.tokens) : [];
|
|
2630
3047
|
const leadToken = {
|
|
2631
3048
|
...token,
|
|
2632
3049
|
items: token.items.map((it, i) => i === index ? { ...it, tokens: leadChildren } : it)
|
|
@@ -2639,6 +3056,13 @@ var Markdown = class _Markdown extends UIComponent {
|
|
|
2639
3056
|
indentStart: indent,
|
|
2640
3057
|
availableWidth: Math.max(1, availableWidth - indent)
|
|
2641
3058
|
};
|
|
3059
|
+
for (const image of leadImages) {
|
|
3060
|
+
const el = this.paragraphImage(image, childMetrics.availableWidth);
|
|
3061
|
+
const wrapper = new MarkdownContainer();
|
|
3062
|
+
el.x = indent;
|
|
3063
|
+
wrapper.add(el);
|
|
3064
|
+
stack.add(wrapper);
|
|
3065
|
+
}
|
|
2642
3066
|
for (let i = leadChildren.length; i < children.length; i++) {
|
|
2643
3067
|
const el = this.renderTokenWithMetrics(children[i], childMetrics);
|
|
2644
3068
|
if (!el) continue;
|
|
@@ -3361,23 +3785,40 @@ var Markdown = class _Markdown extends UIComponent {
|
|
|
3361
3785
|
if (!mathData) return null;
|
|
3362
3786
|
const intrinsicW = exToPx(mathData.widthEx, t.fontSize);
|
|
3363
3787
|
const intrinsicH = exToPx(mathData.heightEx, t.fontSize);
|
|
3364
|
-
const
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3788
|
+
const scale = Math.min(1, availableWidth / intrinsicW);
|
|
3789
|
+
const width = intrinsicW * scale;
|
|
3790
|
+
const height = intrinsicH * scale;
|
|
3791
|
+
const uri = mathData.uri;
|
|
3792
|
+
const math = new RichText(
|
|
3793
|
+
[
|
|
3794
|
+
{
|
|
3795
|
+
text: OBJECT_REPLACEMENT,
|
|
3796
|
+
object: {
|
|
3797
|
+
width,
|
|
3798
|
+
height,
|
|
3799
|
+
// The TeX source is what a reader copies and what a screen reader
|
|
3800
|
+
// announces. KaTeX's dual-layer contract carries the same string in an
|
|
3801
|
+
// `<annotation encoding="application/x-tex">`; here the projection is
|
|
3802
|
+
// the semantic layer, so one copy of the source serves both.
|
|
3803
|
+
alt: formula,
|
|
3804
|
+
paint: (surface, box) => paintInlineMath(uri, surface, box)
|
|
3805
|
+
}
|
|
3806
|
+
}
|
|
3807
|
+
],
|
|
3808
|
+
{
|
|
3809
|
+
font: `${t.fontSize}px ${t.bodyFont}`,
|
|
3810
|
+
color: t.textColor,
|
|
3811
|
+
maxWidth: availableWidth,
|
|
3812
|
+
selectable: this.selectable
|
|
3373
3813
|
}
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
wrapper.
|
|
3380
|
-
wrapper.
|
|
3814
|
+
);
|
|
3815
|
+
this.subscribeInlineMathRepaint();
|
|
3816
|
+
const wrapper = new MathBlock(formula, uri);
|
|
3817
|
+
math.x = 16;
|
|
3818
|
+
math.y = 8;
|
|
3819
|
+
wrapper.add(math);
|
|
3820
|
+
wrapper.width = width + 16;
|
|
3821
|
+
wrapper.height = height + 16;
|
|
3381
3822
|
return wrapper;
|
|
3382
3823
|
}
|
|
3383
3824
|
renderToken(token) {
|
|
@@ -3439,7 +3880,7 @@ var Markdown = class _Markdown extends UIComponent {
|
|
|
3439
3880
|
currentTokens = [];
|
|
3440
3881
|
}
|
|
3441
3882
|
};
|
|
3442
|
-
for (const child of pToken.tokens) {
|
|
3883
|
+
for (const child of liftNestedImages(pToken.tokens)) {
|
|
3443
3884
|
if (child.type === "image") {
|
|
3444
3885
|
flushText();
|
|
3445
3886
|
stack.add(this.paragraphImage(child, availableWidth));
|
|
@@ -3467,7 +3908,10 @@ var Markdown = class _Markdown extends UIComponent {
|
|
|
3467
3908
|
const mathBlock = this.renderDisplayMath(codeToken.text, availableWidth);
|
|
3468
3909
|
if (mathBlock) return mathBlock;
|
|
3469
3910
|
}
|
|
3470
|
-
return
|
|
3911
|
+
return this.withBlockAffordances(
|
|
3912
|
+
new CodeBlock(codeToken.text, lang, availableWidth, t, this.selectable),
|
|
3913
|
+
() => this.codeBlockAffordances(codeToken.text, lang)
|
|
3914
|
+
);
|
|
3471
3915
|
}
|
|
3472
3916
|
// ── Blockquotes ──────────────────────────────────────────────────
|
|
3473
3917
|
case "blockquote": {
|
|
@@ -3523,21 +3967,24 @@ var Markdown = class _Markdown extends UIComponent {
|
|
|
3523
3967
|
const rows = tblToken.rows.map(
|
|
3524
3968
|
(row) => row.map((cell) => this.tableCellRichText(cell, false, t))
|
|
3525
3969
|
);
|
|
3526
|
-
return
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3970
|
+
return this.withBlockAffordances(
|
|
3971
|
+
new Table({
|
|
3972
|
+
headers,
|
|
3973
|
+
rows,
|
|
3974
|
+
// `| :--- | :---: | ---: |` already resolves to this on the token; it
|
|
3975
|
+
// was previously discarded, so every column rendered left-aligned.
|
|
3976
|
+
align: tblToken.align,
|
|
3977
|
+
width: availableWidth,
|
|
3978
|
+
textColor: t.textColor,
|
|
3979
|
+
headerTextColor: t.headingColor,
|
|
3980
|
+
font: `${t.fontSize - 2}px ${t.bodyFont}`,
|
|
3981
|
+
borderColor: t.hrColor,
|
|
3982
|
+
bg: t.tableBgColor,
|
|
3983
|
+
headerBg: t.tableHeaderBgColor,
|
|
3984
|
+
selectable: this.selectable
|
|
3985
|
+
}),
|
|
3986
|
+
() => this.tableAffordances(tblToken)
|
|
3987
|
+
);
|
|
3541
3988
|
}
|
|
3542
3989
|
// ── Horizontal rule ──────────────────────────────────────────────
|
|
3543
3990
|
case "hr":
|
|
@@ -3572,12 +4019,22 @@ var Markdown = class _Markdown extends UIComponent {
|
|
|
3572
4019
|
}
|
|
3573
4020
|
};
|
|
3574
4021
|
export {
|
|
4022
|
+
BlockAffordanceButton,
|
|
4023
|
+
BlockWithAffordances,
|
|
3575
4024
|
CodeBlock,
|
|
3576
4025
|
Markdown,
|
|
4026
|
+
MathBlock,
|
|
3577
4027
|
codeAtlas,
|
|
3578
4028
|
codeAtlasStats,
|
|
4029
|
+
escapeCsvField,
|
|
4030
|
+
escapeMarkdownTableCell,
|
|
4031
|
+
extensionForLanguage,
|
|
3579
4032
|
isMathJaxReady,
|
|
4033
|
+
mimeForLanguage,
|
|
3580
4034
|
parseFrontMatterFields,
|
|
3581
4035
|
preloadMathJax,
|
|
3582
|
-
scanFrontMatter
|
|
4036
|
+
scanFrontMatter,
|
|
4037
|
+
tableContentOf,
|
|
4038
|
+
tableToCsv,
|
|
4039
|
+
tableToMarkdown
|
|
3583
4040
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vectojs/markdown",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"scripts": {
|
|
41
41
|
"build": "(cd ../ui && bun run build) && node scripts/build-worker.js && tsup src/index.ts --format cjs,esm --clean && tsc -p tsconfig.build.json",
|
|
42
42
|
"test": "vitest run",
|
|
43
|
-
"test:e2e": "bun e2e/blockquote-layout.e2e.ts && bun e2e/stream-controller.e2e.ts && bun e2e/lazy-math.e2e.ts && bun e2e/paragraph-image-repaint.e2e.ts && bun e2e/selection-fidelity.e2e.ts && bun e2e/code-atlas-dpr.e2e.ts && bun e2e/set-max-width.e2e.ts"
|
|
43
|
+
"test:e2e": "bun e2e/blockquote-layout.e2e.ts && bun e2e/stream-controller.e2e.ts && bun e2e/lazy-math.e2e.ts && bun e2e/paragraph-image-repaint.e2e.ts && bun e2e/selection-fidelity.e2e.ts && bun e2e/code-atlas-dpr.e2e.ts && bun e2e/set-max-width.e2e.ts && bun e2e/block-affordances.e2e.ts"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"marked": "^18.0.7",
|