Skip to content

Commit

Permalink
feat: synchronize scrolling
Browse files Browse the repository at this point in the history
  • Loading branch information
adamdehaven committed Dec 18, 2023
1 parent 19738b9 commit b12604d
Show file tree
Hide file tree
Showing 4 changed files with 87 additions and 18 deletions.
53 changes: 36 additions & 17 deletions src/components/MarkdownUi.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
autocomplete="off"
autocorrect="off"
class="markdown-editor-textarea"
:class="[scrollableClass]"
data-testid="markdown-editor-textarea"
placeholder="Begin editing..."
spellcheck="false"
Expand All @@ -43,15 +44,18 @@
class="markdown-content-container"
data-testid="markdown-content-container"
>
<MarkdownContent :content="htmlPreview ? markdownPreviewHtml : markdownHtml" />
<MarkdownContent
:class="[scrollableClass]"
:content="htmlPreview ? markdownPreviewHtml : markdownHtml"
/>
</div>
</div>
</div>
</div>
</template>

<script setup lang="ts">
import { onBeforeMount, onMounted, computed, ref, nextTick, provide, watch, watchEffect } from 'vue'
import { onBeforeMount, onMounted, onUnmounted, computed, ref, nextTick, provide, watch, watchEffect } from 'vue'
import type { PropType } from 'vue'
import MarkdownToolbar from './MarkdownToolbar.vue'
import MarkdownContent from './MarkdownContent.vue'
Expand Down Expand Up @@ -108,8 +112,10 @@ const emit = defineEmits<{
const { init: initMarkdownIt, md } = composables.useMarkdownIt(props.theme)
// Generate a unique id to handle mutiple components on the same page
const componentContainerId = computed((): string => `markdown-ui-${uuidv4()}`)
const textareaId = computed((): string => `markdown-ui-textarea-${uuidv4()}`)
const uniqueId = uuidv4()
const componentContainerId = computed((): string => `markdown-ui-${uniqueId}`)
const textareaId = computed((): string => `markdown-ui-textarea-${uniqueId}`)
const scrollableClass = computed((): string => `scrollable-${uniqueId}`)
// Provide values to child components
provide(TEXTAREA_ID, computed((): string => textareaId.value))
Expand Down Expand Up @@ -175,6 +181,19 @@ watchEffect(() => {
}
})
const updateMermaid = async () => {
if (props.mermaid) {
// Scope the query selector to this instance of the markdown component (unique container id)
const mermaidNodes = `#${componentContainerId.value} .markdown-content-container .mermaid`
if (typeof MermaidJs !== 'undefined' && typeof MermaidJs?.run === 'function' && document.querySelector(mermaidNodes)) {
await MermaidJs.run({
querySelector: mermaidNodes,
suppressErrors: true,
})
}
}
}
// When the textarea `input` event is triggered, or "faked" by other editor methods, update the Vue refs and rendered markdown
const onContentEdit = async (event: TextAreaInputEvent, emitEvent = true): Promise<void> => {
// Update the ref
Expand All @@ -189,7 +208,7 @@ const onContentEdit = async (event: TextAreaInputEvent, emitEvent = true): Promi
}
// Re-render any `.mermaid` containers
await nextTick()
await nextTick() // **MUST** await nextTick for the virtual DOM to refresh
await updateMermaid()
}
Expand Down Expand Up @@ -244,18 +263,7 @@ onBeforeMount(async () => {
await updateMermaid()
})
const updateMermaid = async () => {
if (props.mermaid) {
// Scope the query selector to this instance of the markdown component (unique container id)
const mermaidNodes = `#${componentContainerId.value} .markdown-content-container .mermaid`
if (typeof MermaidJs !== 'undefined' && typeof MermaidJs?.run === 'function' && document.querySelector(mermaidNodes)) {
await MermaidJs.run({
querySelector: mermaidNodes,
suppressErrors: true,
})
}
}
}
const { initializeSyncScroll, destroySyncScroll } = composables.useSyncScroll(scrollableClass)
onMounted(async () => {
ready.value = true
Expand All @@ -269,6 +277,17 @@ onMounted(async () => {
theme: props.theme === 'light' ? 'default' : 'dark',
})
}
// Must await to let virtual DOM cycle
await nextTick()
// Syncronize container scrolling
initializeSyncScroll()
})
onUnmounted(() => {
// Remove scrolling event listeners
destroySyncScroll()
})
</script>

Expand Down
2 changes: 2 additions & 0 deletions src/composables/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import useKeyboardShortcuts from './useKeyboardShortcuts'
import useMarkdownActions from './useMarkdownActions'
import useMarkdownIt from './useMarkdownIt'
import useShikiji from './useShikiji'
import useSyncScroll from './useSyncScroll'

export default {
useDebounce,
useKeyboardShortcuts,
useMarkdownActions,
useMarkdownIt,
useShikiji,
useSyncScroll,
}
2 changes: 1 addition & 1 deletion src/composables/useKeyboardShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { KEYBOARD_SHORTCUTS } from '../constants'
import type { InlineFormat } from '../types'

/**
* Utilize keyboard shortcuts in the markdown editor.
* Utilize keyboard shortcuts in the markdown editor. Must be called at the root of the `setup` function.
* @param {string} textareaId The `id` of the textarea
* @param {Ref<string>} rawMarkdown A Vue ref containing the raw markdown content from the textarea.
* @param {Function} onEditCallback A function to call after toggling the inline text formatting.
Expand Down
48 changes: 48 additions & 0 deletions src/composables/useSyncScroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { Ref } from 'vue'

export default function useSyncScroll(scrollableClass: Ref<string>) {
// Keep scroll containers in sync
const handleScroll = (e: Event) => {
const syncScroll = (scrolledEle: Element, ele: Element) => {
const scrolledPercent = scrolledEle.scrollTop / (scrolledEle.scrollHeight - scrolledEle.clientHeight)
const top = scrolledPercent * (ele.scrollHeight - ele.clientHeight)

const scrolledWidthPercent = scrolledEle.scrollLeft / (scrolledEle.scrollWidth - scrolledEle.clientWidth)
const left = scrolledWidthPercent * (ele.scrollWidth - ele.clientWidth)

ele.scrollTo({
behavior: 'instant', // must be instant
top,
left,
})
}

const scrolledEle = e.target

Array.from([...document.querySelectorAll(`.${scrollableClass.value}`)]).filter((item) => item !== scrolledEle).forEach((ele: Element) => {
ele.removeEventListener('scroll', handleScroll)
// @ts-ignore
syncScroll(scrolledEle, ele)
window.requestAnimationFrame(() => {
ele.addEventListener('scroll', handleScroll)
})
})
}

const initializeSyncScroll = (): void => {
document?.querySelectorAll(`.${scrollableClass.value}`)?.forEach((el) => {
el?.addEventListener('scroll', handleScroll)
})
}

const destroySyncScroll = (): void => {
document?.querySelectorAll(`.${scrollableClass.value}`)?.forEach((el) => {
el?.removeEventListener('scroll', handleScroll)
})
}

return {
initializeSyncScroll,
destroySyncScroll,
}
}

0 comments on commit b12604d

Please sign in to comment.