tosijs-ui 1.6.7 → 1.6.9
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/code-editor.d.ts +6 -0
- package/dist/code-editor.js +43 -0
- package/dist/diff.d.ts +61 -0
- package/dist/diff.js +132 -0
- package/dist/doc-browser.js +275 -29
- package/dist/doc-system/doc-system-styles.js +4 -1
- package/dist/doc-system/doc-system.js +8 -0
- package/dist/doc-system/site/dev-server.js +56 -0
- package/dist/doc-system/site/generate-site.js +7 -0
- package/dist/doc-system/site/orchestrator.js +3 -1
- package/dist/doc-system/site/site-config.d.ts +7 -0
- package/dist/iife.js +84 -78
- package/dist/iife.js.map +17 -14
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/live-example/code-transform.d.ts +8 -5
- package/dist/live-example/code-transform.js +56 -31
- package/dist/live-example/component.d.ts +23 -1
- package/dist/live-example/component.js +291 -22
- package/dist/live-example/example-store.d.ts +12 -0
- package/dist/live-example/example-store.js +47 -0
- package/dist/live-example/insert-examples.d.ts +1 -1
- package/dist/live-example/insert-examples.js +18 -1
- package/dist/live-example/save-to-source.d.ts +23 -0
- package/dist/live-example/save-to-source.js +95 -0
- package/dist/live-example/styles.d.ts +5 -0
- package/dist/live-example/styles.js +8 -0
- package/dist/live-example/test-harness.js +2 -2
- package/dist/menu.js +14 -4
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/llms.txt +3 -1
- package/package.json +4 -4
package/dist/code-editor.d.ts
CHANGED
|
@@ -4,6 +4,12 @@ export declare class CodeEditor extends WebComponent {
|
|
|
4
4
|
private source;
|
|
5
5
|
get value(): string;
|
|
6
6
|
set value(text: string);
|
|
7
|
+
private _original;
|
|
8
|
+
get original(): string;
|
|
9
|
+
set original(text: string);
|
|
10
|
+
private diffOverlay;
|
|
11
|
+
get showingDiff(): boolean;
|
|
12
|
+
showDiff(on: boolean): void;
|
|
7
13
|
static initAttributes: {
|
|
8
14
|
mode: string;
|
|
9
15
|
theme: string;
|
package/dist/code-editor.js
CHANGED
|
@@ -23,6 +23,7 @@ and an `ace` property that returns the `ace` module, giving you complete access
|
|
|
23
23
|
/*{ "parent": "Components" }*/
|
|
24
24
|
import { Component as WebComponent } from 'tosijs';
|
|
25
25
|
import { scriptTag } from './via-tag';
|
|
26
|
+
import { tosiDiff } from './diff';
|
|
26
27
|
const ACE_BASE_URL = 'https://cdnjs.cloudflare.com/ajax/libs/ace/1.23.2/';
|
|
27
28
|
const DEFAULT_THEME = 'ace/theme/tomorrow';
|
|
28
29
|
const getAce = async () => {
|
|
@@ -37,6 +38,10 @@ const makeCodeEditor = async (codeElement, mode = 'html', options = {}, theme =
|
|
|
37
38
|
tabSize: 2,
|
|
38
39
|
useSoftTabs: true,
|
|
39
40
|
useWorker: false,
|
|
41
|
+
// Wrap wide lines instead of scrolling horizontally; indentedSoftWrap keeps
|
|
42
|
+
// wrapped continuations aligned under the line's indent.
|
|
43
|
+
wrap: true,
|
|
44
|
+
indentedSoftWrap: true,
|
|
40
45
|
...options,
|
|
41
46
|
});
|
|
42
47
|
editor.setTheme(theme);
|
|
@@ -58,6 +63,44 @@ export class CodeEditor extends WebComponent {
|
|
|
58
63
|
this.editor.session.getUndoManager().reset();
|
|
59
64
|
}
|
|
60
65
|
}
|
|
66
|
+
// Baseline for `showDiff()` — the version to diff the current `value` against.
|
|
67
|
+
// Defaults to the current value (no diff) until a caller sets it.
|
|
68
|
+
_original;
|
|
69
|
+
get original() {
|
|
70
|
+
return this._original ?? this.value;
|
|
71
|
+
}
|
|
72
|
+
set original(text) {
|
|
73
|
+
this._original = text;
|
|
74
|
+
}
|
|
75
|
+
// Diff overlay — deliberately built only on the editor's public surface
|
|
76
|
+
// (`value` + `original`) and the tosi-diff component, never the underlying
|
|
77
|
+
// editor's API, so it survives a future Ace → CodeMirror swap untouched.
|
|
78
|
+
diffOverlay;
|
|
79
|
+
get showingDiff() {
|
|
80
|
+
return this.diffOverlay !== undefined && !this.diffOverlay.hidden;
|
|
81
|
+
}
|
|
82
|
+
showDiff(on) {
|
|
83
|
+
if (on) {
|
|
84
|
+
if (this.diffOverlay === undefined) {
|
|
85
|
+
this.diffOverlay = tosiDiff({
|
|
86
|
+
style: {
|
|
87
|
+
position: 'absolute',
|
|
88
|
+
inset: '0',
|
|
89
|
+
zIndex: '5',
|
|
90
|
+
overflow: 'auto',
|
|
91
|
+
background: 'var(--tosi-diff-bg, var(--background, #fff))',
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
this.append(this.diffOverlay);
|
|
95
|
+
}
|
|
96
|
+
this.diffOverlay.original = this.original;
|
|
97
|
+
this.diffOverlay.modified = this.value;
|
|
98
|
+
this.diffOverlay.hidden = false;
|
|
99
|
+
}
|
|
100
|
+
else if (this.diffOverlay !== undefined) {
|
|
101
|
+
this.diffOverlay.hidden = true;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
61
104
|
static initAttributes = {
|
|
62
105
|
mode: 'javascript',
|
|
63
106
|
theme: DEFAULT_THEME,
|
package/dist/diff.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Component, ElementCreator, PartsMap } from 'tosijs';
|
|
2
|
+
export type DiffOp = 'context' | 'add' | 'remove';
|
|
3
|
+
export interface DiffLine {
|
|
4
|
+
op: DiffOp;
|
|
5
|
+
text: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Line-level diff of two strings via longest-common-subsequence, returning each
|
|
9
|
+
* line tagged `context` (unchanged), `remove` (only in `before`), or `add` (only
|
|
10
|
+
* in `after`), in display order.
|
|
11
|
+
*/
|
|
12
|
+
export declare function diffLines(before: string, after: string): DiffLine[];
|
|
13
|
+
interface DiffParts extends PartsMap {
|
|
14
|
+
body: HTMLElement;
|
|
15
|
+
}
|
|
16
|
+
export declare class TosiDiff extends Component<DiffParts> {
|
|
17
|
+
static preferredTagName: string;
|
|
18
|
+
static initAttributes: {
|
|
19
|
+
original: string;
|
|
20
|
+
modified: string;
|
|
21
|
+
};
|
|
22
|
+
static shadowStyleSpec: {
|
|
23
|
+
':host': {
|
|
24
|
+
display: string;
|
|
25
|
+
overflow: string;
|
|
26
|
+
font: string;
|
|
27
|
+
background: string;
|
|
28
|
+
color: string;
|
|
29
|
+
};
|
|
30
|
+
':host([hidden])': {
|
|
31
|
+
display: string;
|
|
32
|
+
};
|
|
33
|
+
'.diff-line': {
|
|
34
|
+
display: string;
|
|
35
|
+
gridTemplateColumns: string;
|
|
36
|
+
whiteSpace: string;
|
|
37
|
+
wordBreak: string;
|
|
38
|
+
};
|
|
39
|
+
'.diff-line .marker': {
|
|
40
|
+
textAlign: string;
|
|
41
|
+
userSelect: string;
|
|
42
|
+
opacity: string;
|
|
43
|
+
};
|
|
44
|
+
'.diff-add': {
|
|
45
|
+
background: string;
|
|
46
|
+
};
|
|
47
|
+
'.diff-add .marker': {
|
|
48
|
+
color: string;
|
|
49
|
+
};
|
|
50
|
+
'.diff-remove': {
|
|
51
|
+
background: string;
|
|
52
|
+
};
|
|
53
|
+
'.diff-remove .marker': {
|
|
54
|
+
color: string;
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
content: () => HTMLDivElement[];
|
|
58
|
+
render(): void;
|
|
59
|
+
}
|
|
60
|
+
export declare const tosiDiff: ElementCreator<TosiDiff>;
|
|
61
|
+
export {};
|
package/dist/diff.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/*#
|
|
2
|
+
# diff
|
|
3
|
+
|
|
4
|
+
`<tosi-diff>` renders a unified, line-by-line diff between two strings — the kind
|
|
5
|
+
of view you'd see in a code review, with removed lines marked `-` and added
|
|
6
|
+
lines marked `+`.
|
|
7
|
+
|
|
8
|
+
Set its `original` and `modified` properties (or attributes) to the two versions
|
|
9
|
+
of the text; it recomputes and re-renders. (They're named `original`/`modified`
|
|
10
|
+
rather than `before`/`after` to avoid the native `Element.before()`/`after()`
|
|
11
|
+
methods.)
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
const { tosiDiff } = tosijsui
|
|
15
|
+
|
|
16
|
+
preview.append(
|
|
17
|
+
tosiDiff({
|
|
18
|
+
original: 'one\ntwo\nthree\nfour',
|
|
19
|
+
modified: 'one\nTWO\nthree\nfour\nfive',
|
|
20
|
+
style: { width: '100%', height: '100%' },
|
|
21
|
+
})
|
|
22
|
+
)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The diff is computed with a longest-common-subsequence pass over the lines, so
|
|
26
|
+
unchanged lines are shown as context and only genuine insertions/deletions are
|
|
27
|
+
highlighted. The pure `diffLines(before, after)` function is exported too, if you
|
|
28
|
+
just want the data.
|
|
29
|
+
*/
|
|
30
|
+
/*{ "parent": "Components" }*/
|
|
31
|
+
import { Component, elements } from 'tosijs';
|
|
32
|
+
const { div, span } = elements;
|
|
33
|
+
/**
|
|
34
|
+
* Line-level diff of two strings via longest-common-subsequence, returning each
|
|
35
|
+
* line tagged `context` (unchanged), `remove` (only in `before`), or `add` (only
|
|
36
|
+
* in `after`), in display order.
|
|
37
|
+
*/
|
|
38
|
+
export function diffLines(before, after) {
|
|
39
|
+
const a = before.split('\n');
|
|
40
|
+
const b = after.split('\n');
|
|
41
|
+
const m = a.length;
|
|
42
|
+
const n = b.length;
|
|
43
|
+
// lcs[i][j] = length of the LCS of a[i:] and b[j:]
|
|
44
|
+
const lcs = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
45
|
+
for (let i = m - 1; i >= 0; i -= 1) {
|
|
46
|
+
for (let j = n - 1; j >= 0; j -= 1) {
|
|
47
|
+
lcs[i][j] =
|
|
48
|
+
a[i] === b[j]
|
|
49
|
+
? lcs[i + 1][j + 1] + 1
|
|
50
|
+
: Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const out = [];
|
|
54
|
+
let i = 0;
|
|
55
|
+
let j = 0;
|
|
56
|
+
while (i < m && j < n) {
|
|
57
|
+
if (a[i] === b[j]) {
|
|
58
|
+
out.push({ op: 'context', text: a[i] });
|
|
59
|
+
i += 1;
|
|
60
|
+
j += 1;
|
|
61
|
+
}
|
|
62
|
+
else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
|
63
|
+
out.push({ op: 'remove', text: a[i] });
|
|
64
|
+
i += 1;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
out.push({ op: 'add', text: b[j] });
|
|
68
|
+
j += 1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
while (i < m) {
|
|
72
|
+
out.push({ op: 'remove', text: a[i] });
|
|
73
|
+
i += 1;
|
|
74
|
+
}
|
|
75
|
+
while (j < n) {
|
|
76
|
+
out.push({ op: 'add', text: b[j] });
|
|
77
|
+
j += 1;
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
const MARKER = { context: ' ', add: '+', remove: '-' };
|
|
82
|
+
export class TosiDiff extends Component {
|
|
83
|
+
static preferredTagName = 'tosi-diff';
|
|
84
|
+
// `before`/`after` would collide with the native Element.before()/after()
|
|
85
|
+
// methods, so the component props are `original`/`modified`.
|
|
86
|
+
static initAttributes = {
|
|
87
|
+
original: '',
|
|
88
|
+
modified: '',
|
|
89
|
+
};
|
|
90
|
+
static shadowStyleSpec = {
|
|
91
|
+
':host': {
|
|
92
|
+
display: 'block',
|
|
93
|
+
overflow: 'auto',
|
|
94
|
+
font: 'var(--tosi-code-font, 12px/1.5 monospace)',
|
|
95
|
+
background: 'var(--tosi-diff-bg, #fff)',
|
|
96
|
+
color: 'var(--tosi-diff-color, #222)',
|
|
97
|
+
},
|
|
98
|
+
// `:host{display:block}` would otherwise beat the UA `[hidden]` rule, so the
|
|
99
|
+
// element couldn't be hidden via the attribute — restore that.
|
|
100
|
+
':host([hidden])': {
|
|
101
|
+
display: 'none',
|
|
102
|
+
},
|
|
103
|
+
'.diff-line': {
|
|
104
|
+
display: 'grid',
|
|
105
|
+
gridTemplateColumns: '1.5em 1fr',
|
|
106
|
+
whiteSpace: 'pre-wrap',
|
|
107
|
+
wordBreak: 'break-word',
|
|
108
|
+
},
|
|
109
|
+
'.diff-line .marker': {
|
|
110
|
+
textAlign: 'center',
|
|
111
|
+
userSelect: 'none',
|
|
112
|
+
opacity: '0.5',
|
|
113
|
+
},
|
|
114
|
+
'.diff-add': {
|
|
115
|
+
background: 'var(--tosi-diff-add-bg, #e6ffed)',
|
|
116
|
+
},
|
|
117
|
+
'.diff-add .marker': { color: 'var(--tosi-diff-add-color, #22863a)' },
|
|
118
|
+
'.diff-remove': {
|
|
119
|
+
background: 'var(--tosi-diff-remove-bg, #ffeef0)',
|
|
120
|
+
},
|
|
121
|
+
'.diff-remove .marker': { color: 'var(--tosi-diff-remove-color, #cb2431)' },
|
|
122
|
+
};
|
|
123
|
+
content = () => [div({ part: 'body' })];
|
|
124
|
+
render() {
|
|
125
|
+
super.render();
|
|
126
|
+
const lines = diffLines(this.original, this.modified);
|
|
127
|
+
this.parts.body.replaceChildren(...lines.map((line) => div({ class: `diff-line diff-${line.op}` }, span({ class: 'marker' }, MARKER[line.op]),
|
|
128
|
+
// Non-breaking fallback so empty lines keep their row height.
|
|
129
|
+
span({ class: 'text' }, line.text === '' ? ' ' : line.text))));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export const tosiDiff = TosiDiff.elementCreator();
|
package/dist/doc-browser.js
CHANGED
|
@@ -160,6 +160,8 @@ import { tosiSidenav, TosiSidenav } from './side-nav';
|
|
|
160
160
|
import { icons } from './icons';
|
|
161
161
|
import { tosiLocalized } from './localize';
|
|
162
162
|
import { popMenu } from './menu';
|
|
163
|
+
import { codeEditor } from './code-editor';
|
|
164
|
+
import { tosiDiff } from './diff';
|
|
163
165
|
const { div, span, a, header, button, template, input, h2, details, summary, ul, li } = elements;
|
|
164
166
|
// Test colors
|
|
165
167
|
const testColor = {
|
|
@@ -169,6 +171,8 @@ const testColor = {
|
|
|
169
171
|
};
|
|
170
172
|
// Test indicator styles - widget inherits button styles from base stylesheet
|
|
171
173
|
const testIndicatorStyleSpec = {
|
|
174
|
+
'.view-source': { opacity: '0.9' },
|
|
175
|
+
'.view-source:hover': { opacity: '1' },
|
|
172
176
|
'@keyframes test-pulse': {
|
|
173
177
|
'0%, 100%': { opacity: '1' },
|
|
174
178
|
'50%': { opacity: '0.7' },
|
|
@@ -402,8 +406,28 @@ export function createDocBrowser(options) {
|
|
|
402
406
|
// back/forward (the host owns history, if any).
|
|
403
407
|
if (!memoryRouting) {
|
|
404
408
|
window.addEventListener('popstate', () => {
|
|
409
|
+
// Back/forward while the source editor is open with unsaved changes: the
|
|
410
|
+
// URL already moved, so on "keep editing" we push it back to match the
|
|
411
|
+
// still-open editor; on discard we close the editor and navigate.
|
|
412
|
+
if (editUI) {
|
|
413
|
+
if (editorHasUnsavedChanges() &&
|
|
414
|
+
!window.confirm('Discard unsaved changes to the source?')) {
|
|
415
|
+
const href = hrefFor(String(editUI.doc.filename));
|
|
416
|
+
window.history.pushState({ href }, '', href);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
closeEditor();
|
|
420
|
+
}
|
|
405
421
|
navigateTo(filenameFromLocation());
|
|
406
422
|
});
|
|
423
|
+
// Reload / tab-close / external navigation: native "leave site?" prompt when
|
|
424
|
+
// there are unsaved source edits (the only guard the browser allows here).
|
|
425
|
+
window.addEventListener('beforeunload', (event) => {
|
|
426
|
+
if (editorHasUnsavedChanges()) {
|
|
427
|
+
event.preventDefault();
|
|
428
|
+
event.returnValue = '';
|
|
429
|
+
}
|
|
430
|
+
});
|
|
407
431
|
}
|
|
408
432
|
const headerContent = [
|
|
409
433
|
button({
|
|
@@ -496,7 +520,9 @@ export function createDocBrowser(options) {
|
|
|
496
520
|
else {
|
|
497
521
|
docContent.innerHTML = renderDocMarkdown(doc.text);
|
|
498
522
|
}
|
|
499
|
-
|
|
523
|
+
// Stamp each example with its source file (for the source↔doc map). doc.path
|
|
524
|
+
// is the extracted file (.md, or a source file with doc comments).
|
|
525
|
+
LiveExample.insertExamples(docContent, context, doc.path || undefined);
|
|
500
526
|
if (routing === 'path') {
|
|
501
527
|
document.title = projectName
|
|
502
528
|
? `${doc.title} — ${projectName}`
|
|
@@ -515,6 +541,10 @@ export function createDocBrowser(options) {
|
|
|
515
541
|
// the onRouteChange callback for memory) and then render the doc. Every nav
|
|
516
542
|
// click funnels through here so memory mode never touches window.history.
|
|
517
543
|
const go = (filename) => {
|
|
544
|
+
// If the source editor is open with unsaved changes, confirm before leaving;
|
|
545
|
+
// on confirm it discards + closes, otherwise navigation is aborted.
|
|
546
|
+
if (!confirmLeaveEditor())
|
|
547
|
+
return;
|
|
518
548
|
if (memoryRouting) {
|
|
519
549
|
onRouteChange?.(slugFor(filename));
|
|
520
550
|
}
|
|
@@ -524,6 +554,220 @@ export function createDocBrowser(options) {
|
|
|
524
554
|
}
|
|
525
555
|
navigateTo(filename);
|
|
526
556
|
};
|
|
557
|
+
// ── Edit page source (Foundation B / #4) ──────────────────────────────────
|
|
558
|
+
// The "view source" affordance opens a menu: edit the whole source file in a
|
|
559
|
+
// code editor that fills the content area, preview the result in-browser, and
|
|
560
|
+
// save (dev: write the repo file via /__docstore/source; the watcher rebuilds)
|
|
561
|
+
// or download it. Source is read from the dev endpoint, falling back to GitHub
|
|
562
|
+
// raw, so editing works on the deployed site too (save there = download).
|
|
563
|
+
const editorModeFor = (p) => p.endsWith('.md')
|
|
564
|
+
? 'markdown'
|
|
565
|
+
: p.endsWith('.css')
|
|
566
|
+
? 'css'
|
|
567
|
+
: p.endsWith('.ts') || p.endsWith('.tjs')
|
|
568
|
+
? 'typescript'
|
|
569
|
+
: 'javascript';
|
|
570
|
+
// Pure mirror of docs.ts extraction: a .md *is* the markdown; a source file is
|
|
571
|
+
// the concatenation of its doc-comment blocks. Lets us preview an edit in the
|
|
572
|
+
// browser with no rebuild.
|
|
573
|
+
const docMarkdownFromSource = (content, p) => {
|
|
574
|
+
if (p.endsWith('.md'))
|
|
575
|
+
return content;
|
|
576
|
+
const blocks = content.match(/\/\*#[\s\S]+?\*\//g) || [];
|
|
577
|
+
return blocks.map((s) => s.substring(3, s.length - 2).trim()).join('\n\n');
|
|
578
|
+
};
|
|
579
|
+
const githubRawUrl = (p) => {
|
|
580
|
+
const gh = projectLinks.github;
|
|
581
|
+
if (!gh || !p)
|
|
582
|
+
return null;
|
|
583
|
+
const m = gh.match(/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
|
|
584
|
+
return m ? `https://raw.githubusercontent.com/${m[1]}/${m[2]}/main/${p}` : null;
|
|
585
|
+
};
|
|
586
|
+
const loadSource = async (p) => {
|
|
587
|
+
try {
|
|
588
|
+
const r = await fetch(`/__docstore/source?file=${encodeURIComponent(p)}`);
|
|
589
|
+
if (r.ok)
|
|
590
|
+
return await r.text();
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
// dev endpoint not available — fall through to GitHub raw
|
|
594
|
+
}
|
|
595
|
+
const raw = githubRawUrl(p);
|
|
596
|
+
if (raw) {
|
|
597
|
+
try {
|
|
598
|
+
const r = await fetch(raw);
|
|
599
|
+
if (r.ok)
|
|
600
|
+
return await r.text();
|
|
601
|
+
}
|
|
602
|
+
catch {
|
|
603
|
+
// offline / no network
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return null;
|
|
607
|
+
};
|
|
608
|
+
const saveSourceToDisk = async (p, content) => {
|
|
609
|
+
try {
|
|
610
|
+
const r = await fetch('/__docstore/source', {
|
|
611
|
+
method: 'POST',
|
|
612
|
+
headers: { 'Content-Type': 'application/json' },
|
|
613
|
+
body: JSON.stringify({ file: p, content }),
|
|
614
|
+
});
|
|
615
|
+
return r.ok;
|
|
616
|
+
}
|
|
617
|
+
catch {
|
|
618
|
+
return false;
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
const downloadText = (filename, content) => {
|
|
622
|
+
const url = URL.createObjectURL(new Blob([content], { type: 'text/plain' }));
|
|
623
|
+
const link = a({ href: url, download: filename });
|
|
624
|
+
link.click();
|
|
625
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
626
|
+
};
|
|
627
|
+
let editUI = null;
|
|
628
|
+
const editorHasUnsavedChanges = () => editUI !== null && editUI.editor.value !== editUI.original;
|
|
629
|
+
// Tear the editor down without re-rendering the current doc (the caller is
|
|
630
|
+
// about to navigate). exitEditSource() restores the current doc instead.
|
|
631
|
+
const closeEditor = () => {
|
|
632
|
+
if (!editUI)
|
|
633
|
+
return;
|
|
634
|
+
editUI.editor.remove();
|
|
635
|
+
editUI = null;
|
|
636
|
+
docContent.style.display = '';
|
|
637
|
+
};
|
|
638
|
+
// Called before in-app navigation: if the editor is open with unsaved changes,
|
|
639
|
+
// confirm discarding them. Returns false to abort navigation.
|
|
640
|
+
const confirmLeaveEditor = () => {
|
|
641
|
+
if (!editUI)
|
|
642
|
+
return true;
|
|
643
|
+
if (editorHasUnsavedChanges() &&
|
|
644
|
+
!window.confirm('Discard unsaved changes to the source?')) {
|
|
645
|
+
return false;
|
|
646
|
+
}
|
|
647
|
+
closeEditor();
|
|
648
|
+
return true;
|
|
649
|
+
};
|
|
650
|
+
// Switch the source editor between the raw editor, the rendered preview, and a
|
|
651
|
+
// diff of the edits against the loaded original. preview/diff render into the
|
|
652
|
+
// doc-content area; edit shows the CodeEditor.
|
|
653
|
+
const setSourceView = (view) => {
|
|
654
|
+
if (!editUI)
|
|
655
|
+
return;
|
|
656
|
+
editUI.view = view;
|
|
657
|
+
if (view === 'preview') {
|
|
658
|
+
docContent.innerHTML = renderDocMarkdown(docMarkdownFromSource(editUI.editor.value, editUI.doc.path));
|
|
659
|
+
LiveExample.insertExamples(docContent, context, editUI.doc.path || undefined);
|
|
660
|
+
}
|
|
661
|
+
else if (view === 'diff') {
|
|
662
|
+
docContent.replaceChildren(tosiDiff({
|
|
663
|
+
original: editUI.original,
|
|
664
|
+
modified: editUI.editor.value,
|
|
665
|
+
style: { display: 'block', width: '100%', height: '100%' },
|
|
666
|
+
}));
|
|
667
|
+
}
|
|
668
|
+
docContent.style.display = view === 'edit' ? 'none' : '';
|
|
669
|
+
editUI.editor.style.display = view === 'edit' ? 'block' : 'none';
|
|
670
|
+
};
|
|
671
|
+
const exitEditSource = () => {
|
|
672
|
+
if (!editUI)
|
|
673
|
+
return;
|
|
674
|
+
const filename = String(app.currentDoc.filename);
|
|
675
|
+
closeEditor();
|
|
676
|
+
navigateTo(filename); // restore the canonical rendered doc
|
|
677
|
+
};
|
|
678
|
+
const saveSourceEdit = async () => {
|
|
679
|
+
if (!editUI)
|
|
680
|
+
return;
|
|
681
|
+
const { doc, editor } = editUI;
|
|
682
|
+
const ok = await saveSourceToDisk(doc.path, editor.value);
|
|
683
|
+
if (ok) {
|
|
684
|
+
editUI.original = editor.value; // saved — this is the new clean baseline
|
|
685
|
+
setSourceView('preview'); // the watcher also rebuilds in the background
|
|
686
|
+
}
|
|
687
|
+
else {
|
|
688
|
+
// No write endpoint (deployed site) — hand the file back for the repo.
|
|
689
|
+
downloadText(doc.path.split('/').pop() || 'source.txt', editor.value);
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
const enterEditSource = async (doc) => {
|
|
693
|
+
if (editUI)
|
|
694
|
+
return;
|
|
695
|
+
const content = await loadSource(doc.path);
|
|
696
|
+
if (content === null) {
|
|
697
|
+
window.alert(`Could not load source for ${doc.path}`);
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
const editor = codeEditor({ mode: editorModeFor(doc.path) });
|
|
701
|
+
// No toolbar — the Source menu carries the edit controls (it adapts while
|
|
702
|
+
// editing), so the editor fills the whole content area.
|
|
703
|
+
editor.style.cssText = 'display:block; width:100%; height:100%; border:none;';
|
|
704
|
+
editor.value = content;
|
|
705
|
+
const container = docContent.parentElement;
|
|
706
|
+
container.append(editor);
|
|
707
|
+
editUI = { editor, doc, view: 'edit', original: content };
|
|
708
|
+
setSourceView('edit');
|
|
709
|
+
};
|
|
710
|
+
const openSourceMenu = (target) => {
|
|
711
|
+
const doc = docs.find((d) => String(d.filename) === String(app.currentDoc.filename));
|
|
712
|
+
if (!doc)
|
|
713
|
+
return;
|
|
714
|
+
const fileName = (path) => path.split('/').pop() || path;
|
|
715
|
+
// While editing, the Source menu becomes the editor's controls (no separate
|
|
716
|
+
// toolbar). Otherwise it offers entry points: edit, view on GitHub, download.
|
|
717
|
+
const menuItems = editUI
|
|
718
|
+
? [
|
|
719
|
+
...(editUI.view !== 'edit'
|
|
720
|
+
? [{ caption: 'Edit', icon: 'edit', action: () => setSourceView('edit') }]
|
|
721
|
+
: []),
|
|
722
|
+
...(editUI.view !== 'preview'
|
|
723
|
+
? [{ caption: 'Preview', icon: 'eye', action: () => setSourceView('preview') }]
|
|
724
|
+
: []),
|
|
725
|
+
{
|
|
726
|
+
caption: 'View changes',
|
|
727
|
+
icon: 'code',
|
|
728
|
+
action: () => setSourceView('diff'),
|
|
729
|
+
enabled: () => editorHasUnsavedChanges(),
|
|
730
|
+
},
|
|
731
|
+
null,
|
|
732
|
+
{ caption: 'Save to source', icon: 'save', action: () => void saveSourceEdit() },
|
|
733
|
+
{
|
|
734
|
+
caption: 'Download',
|
|
735
|
+
icon: 'download',
|
|
736
|
+
action: () => downloadText(fileName(editUI.doc.path), editUI.editor.value),
|
|
737
|
+
},
|
|
738
|
+
null,
|
|
739
|
+
{ caption: 'Close editor', icon: 'x', action: exitEditSource },
|
|
740
|
+
]
|
|
741
|
+
: [
|
|
742
|
+
{ caption: 'Edit page source', icon: 'edit', action: () => void enterEditSource(doc) },
|
|
743
|
+
...(projectLinks.github && doc.path && doc.path !== 'README.md'
|
|
744
|
+
? [
|
|
745
|
+
{
|
|
746
|
+
caption: 'View on GitHub',
|
|
747
|
+
icon: 'github',
|
|
748
|
+
action: () => {
|
|
749
|
+
window.open(`${projectLinks.github}/blob/main/${doc.path}`, '_blank');
|
|
750
|
+
},
|
|
751
|
+
},
|
|
752
|
+
]
|
|
753
|
+
: []),
|
|
754
|
+
{
|
|
755
|
+
caption: 'Download source',
|
|
756
|
+
icon: 'download',
|
|
757
|
+
action: () => void loadSource(doc.path).then((c) => {
|
|
758
|
+
if (c !== null)
|
|
759
|
+
downloadText(fileName(doc.path), c);
|
|
760
|
+
}),
|
|
761
|
+
},
|
|
762
|
+
];
|
|
763
|
+
popMenu({
|
|
764
|
+
target,
|
|
765
|
+
// The trigger is position:fixed at the top-right; `auto` placement opens
|
|
766
|
+
// upward into zero space and collapses the menu, so open below-left.
|
|
767
|
+
position: 'sw',
|
|
768
|
+
menuItems,
|
|
769
|
+
});
|
|
770
|
+
};
|
|
527
771
|
// ── Hierarchical nav (path routing) ───────────────────────────────────────
|
|
528
772
|
// Build nested <details> from the doc tree; current-highlight, test status,
|
|
529
773
|
// search visibility, and auto-open are applied imperatively by refreshNav.
|
|
@@ -686,49 +930,47 @@ export function createDocBrowser(options) {
|
|
|
686
930
|
overflowY: 'scroll',
|
|
687
931
|
height: '100%',
|
|
688
932
|
},
|
|
689
|
-
},
|
|
933
|
+
}, button({
|
|
690
934
|
class: 'view-source',
|
|
691
|
-
target: '_blank',
|
|
692
935
|
style: {
|
|
693
|
-
display:
|
|
936
|
+
display: 'flex',
|
|
694
937
|
alignItems: 'center',
|
|
695
938
|
gap: '6px',
|
|
696
939
|
position: 'fixed',
|
|
697
940
|
top: 'calc(var(--xin-header-height, 60px) + 5px)',
|
|
698
941
|
right: '5px',
|
|
942
|
+
// Above the code editor (which fills the content area); below the
|
|
943
|
+
// popped menu (z 1002).
|
|
944
|
+
zIndex: '100',
|
|
699
945
|
fontSize: '0.875em',
|
|
700
946
|
color: 'var(--brand-color, inherit)',
|
|
701
|
-
|
|
702
|
-
|
|
947
|
+
// Solid, theme-aware chip so it stays legible over the editor.
|
|
948
|
+
background: 'var(--background, white)',
|
|
949
|
+
border: 'none',
|
|
950
|
+
borderRadius: '4px',
|
|
951
|
+
padding: '3px 8px',
|
|
952
|
+
boxShadow: '0 1px 4px #0003',
|
|
953
|
+
cursor: 'pointer',
|
|
703
954
|
transition: 'opacity 0.2s ease',
|
|
704
955
|
},
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
;
|
|
711
|
-
|
|
956
|
+
// Opacity + :hover live in CSS (.view-source) — a JS hover handler
|
|
957
|
+
// here would fade the delegation root (the whole page), not the button.
|
|
958
|
+
onClick(event) {
|
|
959
|
+
// tosijs delegates clicks, so event.currentTarget is the delegation
|
|
960
|
+
// root, not the button — resolve the real trigger for menu anchoring.
|
|
961
|
+
const btn = event.target.closest('.view-source');
|
|
962
|
+
if (btn)
|
|
963
|
+
openSourceMenu(btn);
|
|
712
964
|
},
|
|
713
965
|
bind: {
|
|
714
966
|
value: app.currentDoc,
|
|
715
967
|
binding(element, doc) {
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
element.href = `${projectLinks.github}/blob/main/${doc.path}`;
|
|
720
|
-
element.style.display = 'flex';
|
|
721
|
-
}
|
|
722
|
-
else {
|
|
723
|
-
element.style.display = 'none';
|
|
724
|
-
}
|
|
968
|
+
// Show when there's a source file to edit/view (any doc with a
|
|
969
|
+
// path); the menu handles dev-vs-GitHub availability per item.
|
|
970
|
+
element.style.display = doc.path ? 'flex' : 'none';
|
|
725
971
|
},
|
|
726
972
|
},
|
|
727
|
-
}, icons.
|
|
728
|
-
style: {
|
|
729
|
-
_xinIconSize: 16,
|
|
730
|
-
},
|
|
731
|
-
}), 'View source on GitHub'), docContent)));
|
|
973
|
+
}, icons.code({ style: { _xinIconSize: 16 } }), 'Source'), docContent)));
|
|
732
974
|
// Render the landing doc (adopts pre-rendered HTML when hydrating).
|
|
733
975
|
showDoc(currentDoc);
|
|
734
976
|
// Inject test indicator styles
|
|
@@ -915,10 +1157,14 @@ export function createDocBrowser(options) {
|
|
|
915
1157
|
return;
|
|
916
1158
|
}
|
|
917
1159
|
const currentFilename = String(app.currentDoc.filename);
|
|
918
|
-
// Create a hidden iframe that loads the full page
|
|
1160
|
+
// Create a hidden iframe that loads the full page. Keep its real 800x600
|
|
1161
|
+
// layout size (layout-dependent example tests need it) but park it
|
|
1162
|
+
// off-screen rather than rely on opacity:0 over the visible page — Chromium
|
|
1163
|
+
// (and haltija on top of it) still composites a 0-opacity layer at 0,0,
|
|
1164
|
+
// which flashes as the frame navigates page to page.
|
|
919
1165
|
const testFrame = document.createElement('iframe');
|
|
920
1166
|
testFrame.style.cssText =
|
|
921
|
-
'position: fixed; left:
|
|
1167
|
+
'position: fixed; left: -10000px; top: 0; width: 800px; height: 600px; opacity: 0; pointer-events: none;';
|
|
922
1168
|
document.body.appendChild(testFrame);
|
|
923
1169
|
// Listen for test results posted from the iframe
|
|
924
1170
|
const messageHandler = (event) => {
|
|
@@ -241,7 +241,10 @@ export function docSystemStyleSpec(theme = {}) {
|
|
|
241
241
|
overflow: 'hidden',
|
|
242
242
|
position: 'relative',
|
|
243
243
|
},
|
|
244
|
-
|
|
244
|
+
// Scope the icon styling (currentColor fill, click-through) to actual icons
|
|
245
|
+
// and SVGs inside buttons — a blanket `svg` rule sets pointer-events:none on
|
|
246
|
+
// every SVG, which breaks interactive SVG-based UIs (maps, charts, diagrams).
|
|
247
|
+
'svg.tosi-icon, button svg': {
|
|
245
248
|
fill: 'currentColor',
|
|
246
249
|
pointerEvents: 'none',
|
|
247
250
|
},
|
|
@@ -369,6 +369,14 @@ export class TosiDocSystem extends Component {
|
|
|
369
369
|
header.append(this.settingsButton());
|
|
370
370
|
}
|
|
371
371
|
this.replaceChildren(this.browser);
|
|
372
|
+
// Hydration done: reveal the page (the generated <head> hid it to mask the
|
|
373
|
+
// static→live swap). Only the page-owning instance touches document.body;
|
|
374
|
+
// a nested demo must not. Next frame so the browser has painted first.
|
|
375
|
+
if (!nested && typeof document !== 'undefined') {
|
|
376
|
+
requestAnimationFrame(() => {
|
|
377
|
+
document.body.style.opacity = '1';
|
|
378
|
+
});
|
|
379
|
+
}
|
|
372
380
|
}
|
|
373
381
|
}
|
|
374
382
|
export const tosiDocSystem = TosiDocSystem.elementCreator();
|