Skip to content

Commit f354f00

Browse files
committed
feat: add toc and cheatsheet
1 parent c95b920 commit f354f00

File tree

22 files changed

+671
-375
lines changed

22 files changed

+671
-375
lines changed

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@
3636
"mustache": "^4.0.1",
3737
"node-sass": "^4.14.1",
3838
"npm-run-all": "^4.1.5",
39-
"prettier": "^2.1.2",
4039
"rollup": "^2.32.1",
4140
"rollup-plugin-livereload": "^2.0.0",
4241
"rollup-plugin-postcss": "^3.1.8",
@@ -50,5 +49,8 @@
5049
"ts-jest": "^26.4.3",
5150
"typescript": "^4.0.5",
5251
"vue-template-compiler": "^2.6.12"
52+
},
53+
"resolutions": {
54+
"prettier": "^2.0.0"
5355
}
5456
}

packages/bytemd/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@
3636
"unified": "^9.0.0"
3737
},
3838
"devDependencies": {
39+
"@types/classnames": "^2.2.11",
3940
"@types/lodash-es": "^4.17.4",
41+
"classnames": "^2.2.6",
4042
"lodash-es": "^4.17.15"
4143
}
4244
}

packages/bytemd/src/editor.svelte

Lines changed: 216 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,18 @@
22

33
<script lang="ts">
44
import type { Editor } from 'codemirror';
5+
import type { Root, Element } from 'hast';
56
import type { BytemdPlugin, EditorProps, ViewerProps } from './types';
67
import { onMount, createEventDispatcher, onDestroy, tick } from 'svelte';
7-
import { debounce } from 'lodash-es';
8+
import { debounce, throttle } from 'lodash-es';
9+
import cx from 'classnames';
810
import Toolbar from './toolbar.svelte';
911
import Viewer from './viewer.svelte';
10-
import { createUtils } from './editor';
11-
import { scrollSync } from './plugins';
12+
import Toc from './toc.svelte';
13+
import { createUtils, findStartIndex } from './editor';
1214
import Status from './status.svelte';
15+
import Help from './help.svelte';
16+
import { icons } from './icons';
1317
1418
export let value: EditorProps['value'] = '';
1519
export let plugins: NonNullable<EditorProps['plugins']> = [];
@@ -19,73 +23,80 @@
1923
export let placeholder: EditorProps['placeholder'];
2024
export let editorConfig: EditorProps['editorConfig'];
2125
22-
let scrollSyncInstance = scrollSync();
23-
24-
$: fullPlugins = (() => {
25-
const ps = [...plugins];
26-
if (mode === 'split' && scrollSyncEnabled) {
27-
ps.push(scrollSyncInstance);
28-
}
29-
return ps;
30-
})();
31-
3226
let el: HTMLElement;
3327
let previewEl: HTMLElement;
34-
let viewerProps: ViewerProps = {
35-
value,
36-
plugins,
37-
sanitize,
38-
};
3928
let textarea: HTMLTextAreaElement;
29+
30+
let viewerProps: ViewerProps = { value, plugins, sanitize };
4031
let editor: Editor;
4132
let activeTab = 0;
4233
let fullscreen = false;
43-
let scrollSyncEnabled = true;
34+
let sidebar: false | 'help' | 'toc' = 'toc'; // false;
35+
36+
$: styles = (() => {
37+
let edit: string;
38+
let preview: string;
39+
40+
if (mode === 'tab') {
41+
if (activeTab === 0) {
42+
edit = `width:calc(100% - ${sidebar ? 280 : 0}px)`;
43+
preview = 'display:none';
44+
} else {
45+
edit = 'display:none';
46+
preview = `width:calc(100% - ${sidebar ? 280 : 0}px)`;
47+
}
48+
} else {
49+
if (sidebar) {
50+
edit = `width:calc(50% - ${sidebar ? 140 : 0}px)`;
51+
preview = `width:calc(50% - ${sidebar ? 140 : 0}px)`;
52+
} else {
53+
edit = 'width:50%';
54+
preview = 'width:50%';
55+
}
56+
}
57+
58+
return { edit, preview };
59+
})();
4460
4561
$: context = { editor, $el: el, utils: createUtils(editor) };
4662
4763
let cbs: ReturnType<NonNullable<BytemdPlugin['editorEffect']>>[] = [];
4864
const dispatch = createEventDispatcher();
4965
50-
// @ts-ignore
51-
function setActiveTab(e) {
52-
activeTab = e.detail.value;
53-
if (editor && activeTab === 0) {
54-
tick().then(() => {
55-
editor.focus();
56-
});
57-
}
58-
}
59-
6066
function on() {
61-
// console.log('on', fullPlugins);
62-
cbs = fullPlugins.map((p) => p.editorEffect?.(context));
67+
// console.log('on', plugins);
68+
cbs = plugins.map((p) => p.editorEffect?.(context));
6369
}
6470
function off() {
65-
// console.log('off', fullPlugins);
71+
// console.log('off', plugins);
6672
cbs.forEach((cb) => cb && cb());
6773
}
6874
6975
const updateViewerValue = debounce(() => {
70-
viewerProps = {
71-
value,
72-
plugins: fullPlugins,
73-
sanitize,
74-
};
76+
viewerProps = { value, plugins, sanitize };
7577
}, previewDebounce);
7678
7779
$: if (editor && value !== editor.getValue()) {
7880
editor.setValue(value);
7981
}
8082
$: if (value != null) updateViewerValue();
8183
82-
$: if (editor && el && fullPlugins) {
84+
$: if (editor && el && plugins && hast) {
8385
off();
8486
tick().then(() => {
8587
on();
8688
});
8789
}
8890
91+
// Scroll sync vars
92+
let scrollSyncEnabled = true;
93+
let editCalled = false;
94+
let previewCalled = false;
95+
let editPs: number[];
96+
let previewPs: number[];
97+
let hast: Root = { type: 'root', children: [] };
98+
let currentBlockIndex = 0;
99+
89100
onMount(async () => {
90101
const [codemirror] = await Promise.all([
91102
import('codemirror'),
@@ -104,52 +115,203 @@
104115
});
105116
106117
// https://github.com/codemirror/CodeMirror/issues/2428#issuecomment-39315423
107-
editor.addKeyMap({
108-
'Shift-Tab': 'indentLess',
109-
});
118+
editor.addKeyMap({ 'Shift-Tab': 'indentLess' });
110119
editor.setValue(value);
111120
editor.on('change', (doc, change) => {
112121
dispatch('change', { value: editor.getValue() });
113122
});
114123
124+
const updateBlockPositions = throttle(() => {
125+
editPs = [];
126+
previewPs = [];
127+
128+
const scrollInfo = editor.getScrollInfo();
129+
const body = previewEl.querySelector<HTMLElement>('.markdown-body')!;
130+
131+
const leftNodes = hast.children.filter(
132+
(v) => v.type === 'element'
133+
) as Element[];
134+
const rightNodes = [...body.childNodes].filter(
135+
(v): v is HTMLElement => v instanceof HTMLElement
136+
);
137+
138+
for (let i = 0; i < leftNodes.length; i++) {
139+
const leftNode = leftNodes[i];
140+
const rightNode = rightNodes[i];
141+
142+
// if there is no position info, move to the next node
143+
if (!leftNode.position) {
144+
continue;
145+
}
146+
147+
const left =
148+
editor.heightAtLine(leftNode.position.start.line - 1, 'local') /
149+
(scrollInfo.height - scrollInfo.clientHeight);
150+
const right =
151+
(rightNode.offsetTop - body.offsetTop) /
152+
(previewEl.scrollHeight - previewEl.clientHeight);
153+
154+
if (left >= 1 || right >= 1) {
155+
break;
156+
}
157+
158+
editPs.push(left);
159+
previewPs.push(right);
160+
}
161+
162+
editPs.push(1);
163+
previewPs.push(1);
164+
// console.log(editPs, previewPs);
165+
}, 1000);
166+
const editorScrollHandler = () => {
167+
if (!scrollSyncEnabled) return;
168+
169+
if (previewCalled) {
170+
previewCalled = false;
171+
return;
172+
}
173+
174+
updateBlockPositions();
175+
176+
const info = editor.getScrollInfo();
177+
const leftRatio = info.top / (info.height - info.clientHeight);
178+
179+
const startIndex = findStartIndex(leftRatio, editPs);
180+
181+
const rightRatio =
182+
((leftRatio - editPs[startIndex]) *
183+
(previewPs[startIndex + 1] - previewPs[startIndex])) /
184+
(editPs[startIndex + 1] - editPs[startIndex]) +
185+
previewPs[startIndex];
186+
// const rightRatio = rightPs[startIndex]; // for testing
187+
188+
previewEl.scrollTo(
189+
0,
190+
rightRatio * (previewEl.scrollHeight - previewEl.clientHeight)
191+
);
192+
editCalled = true;
193+
};
194+
const previewScrollHandler = () => {
195+
// find the current block in the view
196+
updateBlockPositions();
197+
currentBlockIndex = findStartIndex(
198+
previewEl.scrollTop / (previewEl.scrollHeight - previewEl.offsetHeight),
199+
previewPs
200+
);
201+
202+
if (!scrollSyncEnabled) return;
203+
204+
if (editCalled) {
205+
editCalled = false;
206+
return;
207+
}
208+
209+
const rightRatio =
210+
previewEl.scrollTop / (previewEl.scrollHeight - previewEl.clientHeight);
211+
212+
const startIndex = findStartIndex(rightRatio, previewPs);
213+
214+
const leftRatio =
215+
((rightRatio - previewPs[startIndex]) *
216+
(editPs[startIndex + 1] - editPs[startIndex])) /
217+
(previewPs[startIndex + 1] - previewPs[startIndex]) +
218+
editPs[startIndex];
219+
220+
const info = editor.getScrollInfo();
221+
editor.scrollTo(0, leftRatio * (info.height - info.clientHeight));
222+
previewCalled = true;
223+
};
224+
225+
editor.on('scroll', editorScrollHandler);
226+
previewEl.addEventListener('scroll', previewScrollHandler, {
227+
passive: true,
228+
});
229+
115230
// No need to call `on` because cm instance would change once after init
116231
});
117232
onDestroy(off);
118233
</script>
119234

120235
<div
121-
class={`bytemd bytemd-mode-${mode}${fullscreen ? ' bytemd-fullscreen' : ''}`}
236+
class={cx('bytemd', `bytemd-mode-${mode}`, {
237+
'bytemd-fullscreen': fullscreen,
238+
// 'bytemd-sidebar-open': sidebar,
239+
})}
122240
bind:this={el}
123241
>
124242
<Toolbar
125243
{context}
126244
{mode}
127245
{activeTab}
246+
{sidebar}
128247
{plugins}
129248
{fullscreen}
130-
on:tab={setActiveTab}
131-
on:fullscreen={() => {
132-
fullscreen = !fullscreen;
249+
on:tab={(e) => {
250+
activeTab = e.detail;
251+
if (activeTab === 0 && editor) {
252+
tick().then(() => {
253+
editor.focus();
254+
});
255+
}
256+
}}
257+
on:click={(e) => {
258+
switch (e.detail) {
259+
case 'fullscreen':
260+
fullscreen = !fullscreen;
261+
break;
262+
case 'help':
263+
if (sidebar === 'help') {
264+
sidebar = false;
265+
} else {
266+
sidebar = 'help';
267+
}
268+
break;
269+
case 'toc':
270+
if (sidebar === 'toc') {
271+
sidebar = false;
272+
} else {
273+
sidebar = 'toc';
274+
}
275+
break;
276+
}
133277
}}
134278
/>
135279
<div class="bytemd-body">
136-
<div
137-
class="bytemd-editor"
138-
style={mode === 'tab' && activeTab === 1 ? 'display:none' : undefined}
139-
>
280+
<div class="bytemd-editor" style={styles.edit}>
140281
<textarea bind:this={textarea} style="display:none" />
141282
</div>
142-
<div
143-
bind:this={previewEl}
144-
class="bytemd-preview"
145-
style={mode === 'tab' && activeTab === 0 ? 'display:none' : undefined}
146-
>
283+
<div bind:this={previewEl} class="bytemd-preview" style={styles.preview}>
147284
<Viewer
148285
value={viewerProps.value}
149286
plugins={viewerProps.plugins}
150287
sanitize={viewerProps.sanitize}
288+
on:hast={(e) => {
289+
hast = e.detail;
290+
}}
151291
/>
152292
</div>
293+
<div class="bytemd-sidebar" style={sidebar ? undefined : 'display:none'}>
294+
<div
295+
class="bytemd-sidebar-close"
296+
on:click={() => {
297+
sidebar = false;
298+
}}
299+
>
300+
{@html icons.close}
301+
</div>
302+
{#if sidebar === 'help'}
303+
<Help {plugins} />
304+
{:else if sidebar === 'toc'}
305+
<Toc
306+
{hast}
307+
{currentBlockIndex}
308+
on:click={(e) => {
309+
const headings = previewEl.querySelectorAll('h1,h2,h3,h4,h5,h6');
310+
headings[e.detail].scrollIntoView();
311+
}}
312+
/>
313+
{/if}
314+
</div>
153315
</div>
154316
<Status
155317
scrollVisible={mode === 'split'}

0 commit comments

Comments
 (0)