<header class="w-screen bg-orange-900 h-64"></header>
<main id="app" class="wrapper">
<aside class="sticky top-0 ml-8 pt-16">
<transition-group name="list" tag="ul" ref="links">
<li v-for="(item, index) in activeHeadings" v-bind:key="item.id">
<a @click="(e) => handleLinkClick(e, item.id)" :href="item.id" v-bind:class="{
'is-active': item.id === visibleId,
'is-child': item.depth === 2}">
{{ item.text }}
</a>
</li>
</transition-group>
</aside>
<div class="max-w-2xl content mt-32" v-html="compiledMarkdown" ref="content"></div>
</main>
<footer class="w-screen bg-orange-900 h-64 mt-16"></footer>
html {
height: 100%;
}
body {
display: flex;
flex-direction: column;
margin: auto;
background-color: white;
font-family: 'Cabin', sans-serif;
font-weight: 400;
line-height: 1.65;
color: #333;
font-size: 20px;
&::before {
content: "";
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: .05;
z-index: -1;
background: url("https://static.thenounproject.com/png/2590971-200.png");
background-size: 200px 200px;
background-repeat: repeat;
background-position: center center;
}
}
h1, h2, h3, h4, h5 {
font-family: 'Oswald', sans-serif;
margin: 2.75rem 0 1.05rem;
font-weight: 400;
line-height: 1.15;
}
h1 {
font-size: 3.052em;
}
h2 {font-size: 2.441em;}
h3 {font-size: 1.953em;}
h4 {font-size: 1.563em;}
h5 {font-size: 1.25em;}
small, .text_small {font-size: 0.8em;}
header {
background-image: url("https://source.unsplash.com/N09f4fB92hI/1800x900");
background-size: cover;
background-position: top center;
}
footer {
background-image: url("https://source.unsplash.com/xobfytNlC8Y/1800x900");
background-size: cover;
background-position: bottom center;
}
.sticky {
position: sticky;
top: 0;
}
.wrapper {
margin: 0 auto;
display: grid;
padding: 10px;
grid-template-columns: 300px auto;
grid-gap: 30px;
align-items: flex-start;
}
.is-active {
font-weight: 700;
color: #bb2205;
}
.is-child {
padding-left: 1em;
}
.list-leave-active, .list-move {
transition:
transform 0.8s,
opacity 0.4s;
}
.list-enter-active {
transition:
transform 0.8s ease 0.4s,
opacity 0.4s ease 0.4s;
}
.list-enter, .list-leave-to {
opacity: 0;
transform: translateY(10px);
}
.list-leave-active {
position: absolute;
}
View Compiled
const App = {
el: '#app',
data() { return {
input: '# Loading...',
visibleId: '',
previousVisibleId: '',
mdLength: 0,
headings: [],
}},
async created() {
let url = 'https://gist.githubusercontent.com/lisilinhart/e9dcf5298adff7c2c2a4da9ce2a3db3f/raw/2f1a0d47eba64756c22460b5d2919d45d8118d42/red_panda.md'
let response = await fetch(url)
let data = await response.text()
this.input = data
},
mounted() {
let options = {
rootMargin: "0px 0px -200px 0px",
threshold: 1
}
const debouncedFunction = this.debounce(this.handleObserver)
this.observer = new IntersectionObserver(debouncedFunction,options)
this.motionQuery = window.matchMedia('(prefers-reduced-motion)')
},
methods: {
findHeadings() {
if(this.observer) {
this.headings = [...this.$refs.content.querySelectorAll('[id]')]
this.headings.map(heading => this.observer.observe(heading))
}
},
slugify(text) {
return text.toString()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^\w-]+/g, '')
.replace(/--+/g, '-')
},
getRelated(item) {
if(item) {
const items = this.compiledHeadings
const currentIdx = items.indexOf(item)
let idx = 0
// find the correct (parent) index
if(item.depth === 1) {
idx = currentIdx + 1
} else {
// find parent index
let found = false
for (let j = currentIdx; j >= 0; j--) {
if(items[j].depth === 1 && !found) {
idx = j + 1
found = true
}
}
}
let children = []
let isSameLevel = true
for (idx; idx < items.length; idx++) {
if(items[idx].depth === 2 && isSameLevel) {
children.push(items[idx])
}
else if(items[idx].depth === 1) { isSameLevel = false }
}
return children
}
},
handleObserver(entries, observer) {
entries.forEach((entry)=> {
const { target, isIntersecting, intersectionRatio } = entry
if (isIntersecting && intersectionRatio >= 1) {
this.visibleId = `#${target.getAttribute('id')}`
}
})
},
handleLinkClick(evt, itemId) {
evt.preventDefault()
let id = itemId.replace('#', '')
let section = this.headings.find(heading => heading.getAttribute('id') === id)
section.setAttribute('tabindex', -1)
section.focus()
this.visibleId = itemId
window.scroll({
behavior: this.motionQuery.matches ? 'instant' : 'smooth',
top: section.offsetTop -20,
block: 'start'
})
},
debounce(fn) {
var timeout;
return function () {
var context = this;
var args = arguments;
if (timeout) {
window.cancelAnimationFrame(timeout);
}
timeout = window.requestAnimationFrame(function () {
fn.apply(context, args);
});
}
}
},
watch: {
mdLength: function (val) {
this.findHeadings()
},
},
computed: {
compiledMarkdown () {
let htmlFromMarkdown = marked(this.input, { sanitize: true });
this.mdLength = htmlFromMarkdown.length
return htmlFromMarkdown
},
compiledHeadings() {
let regexString = /#(.*)/g
const found = this.input.match(regexString);
const headings = found.map(item => {
let depth = (item.match(/#/g) || []).length
let text = item.replace(/#/gi, '','').trim()
return {
depth,
id: `#${this.slugify(text)}`,
text
}
})
return headings
},
activeHeadings() {
let activeItem = this.compiledHeadings.find(item => item.id === this.visibleId)
let relatedItems = this.getRelated(activeItem) || []
return this.compiledHeadings.filter(item => item.depth === 1 || relatedItems.includes(item))
}
},
};
new Vue(App)
View Compiled