hexo-shiki-highlight
Version:
个人博客[vluv's space](https://vluv.space/)使用的代码高亮插件,基于 [Shiki](https://shiki.style/) 实现。
357 lines (299 loc) • 11.2 kB
JavaScript
// Constants and Configuration
const SELECTORS = {
figureHighlight: 'figure.shiki',
preCode: 'pre code',
codeblock: 'div.codeblock .code pre',
gutter: '.gutter',
gutterPre: '.gutter pre',
preShiki: 'pre.shiki',
expandBtn: '.code-expand-btn'
};
const CLASSES = {
copyTrue: 'copy-true',
closed: 'closed',
expandDone: 'expand-done',
wrapActive: 'wrap-active'
};
const ICONS = {
lineNumber: '<i class="fa-solid fa-list-ol" title="Toggle Line Numbers"></i>',
wrap: '<i class="fa-solid fa-arrow-down-wide-short" title="Toggle Wrap"></i>',
copy: '<div class="copy-notice"></div><i class="fas fa-paste copy-button"></i>',
raw: '<i class="fas fa-file-alt raw-button" title="View Raw"></i>',
expand: '<i class="fas fa-angle-down expand"></i>',
expandCode: '<i class="fas fa-angle-double-down"></i>',
trafficLights: `
<div class="traffic-lights">
<span class="traffic-light red"></span>
<span class="traffic-light yellow"></span>
<span class="traffic-light green"></span>
</div>
`
};
// Utility Functions
const Utils = {
isHidden: (element) => element.offsetHeight === 0 && element.offsetWidth === 0,
showAlert: (element, text, duration = 800) => {
element.textContent = text;
element.style.opacity = 1;
element.style.visibility = 'visible';
setTimeout(() => {
element.style.opacity = 0;
element.style.visibility = 'hidden';
}, duration);
},
createElement: (tag, className, innerHTML) => {
const element = document.createElement(tag);
if (className) element.className = className;
if (innerHTML) element.innerHTML = innerHTML;
return element;
},
toggleDisplay: (elements, show) => {
elements.forEach(element => {
element.style.display = show ? 'flex' : 'none';
});
}
};
// Copy functionality
const CopyHandler = {
async copy(text, noticeElement) {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
} else {
this.fallbackCopy(text);
}
console.log('Text copied successfully:', text);
Utils.showAlert(noticeElement, CODE_CONFIG.copy.success);
} catch (err) {
console.error('Failed to copy:', err);
Utils.showAlert(noticeElement, CODE_CONFIG.copy.error);
}
},
fallbackCopy(text) {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.cssText = 'position: fixed; opacity: 0;';
document.body.appendChild(textarea);
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
try {
const successful = document.execCommand('copy');
if (!successful) throw new Error('execCommand failed');
} finally {
document.body.removeChild(textarea);
}
}
};
// Feature Handlers
const FeatureHandlers = {
copy(parentElement, clickElement) {
const buttonParent = parentElement.parentNode;
buttonParent.classList.add(CLASSES.copyTrue);
const codeElement = buttonParent.querySelector(SELECTORS.preCode);
if (codeElement) {
CopyHandler.copy(codeElement.innerText, clickElement.previousElementSibling);
}
buttonParent.classList.remove(CLASSES.copyTrue);
},
shrink(element) {
const expandButton = element.querySelector('.expand');
expandButton?.classList.toggle(CLASSES.closed);
const siblings = [...element.parentNode.children].slice(1);
const isHidden = Utils.isHidden(siblings[siblings.length - 1]);
Utils.toggleDisplay(siblings, isHidden);
},
raw(element) {
const buttonParent = element.parentNode;
const codeElement = buttonParent.querySelector(SELECTORS.codeblock);
if (!codeElement) {
console.error('Code element not found!');
return;
}
const rawWindow = window.open();
if (!rawWindow) {
console.error('Failed to open window. Please allow pop-ups.');
return;
}
const preElement = rawWindow.document.createElement('pre');
preElement.textContent = codeElement.textContent;
rawWindow.document.body.appendChild(preElement);
// Style the new window
Object.assign(rawWindow.document.body.style, {
margin: '0',
padding: '1rem',
backgroundColor: '#f5f5f5',
fontFamily: 'monospace'
});
rawWindow.document.title = 'Code Raw Content';
},
toggleLineNumbers(element) {
const figure = element.closest(SELECTORS.figureHighlight);
const gutter = figure?.querySelector(SELECTORS.gutter);
if (gutter) {
gutter.style.display = gutter.style.display === 'none' ? '' : 'none';
}
},
toggleWrap(element) {
const figure = element.closest(SELECTORS.figureHighlight);
const pre = figure?.querySelector(SELECTORS.preShiki);
const code = pre?.querySelector('code');
const gutter = figure?.querySelector(SELECTORS.gutter);
const gutterPre = gutter?.querySelector('pre');
if (!pre || !code || !gutter || !gutterPre) {
console.error('Required elements not found!');
return;
}
const isWrapped = pre.style.whiteSpace === 'pre-wrap';
if (isWrapped) {
this.disableWrap(pre, code, gutterPre, element);
} else {
this.enableWrap(pre, code, gutterPre, element);
}
},
disableWrap(pre, code, gutterPre, element) {
Object.assign(pre.style, { whiteSpace: 'pre' });
Object.assign(code.style, {
whiteSpace: 'pre',
wordBreak: 'normal',
overflowWrap: 'normal'
});
element.classList.remove(CLASSES.wrapActive);
// Restore original line numbers
const lineCount = code.textContent.split('\n').length;
gutterPre.innerHTML = Array.from(
{ length: lineCount },
(_, i) => `<span class="line">${i + 1}</span><br>`
).join('');
},
enableWrap(pre, code, gutterPre, element) {
Object.assign(pre.style, { whiteSpace: 'pre-wrap' });
Object.assign(code.style, {
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
overflowWrap: 'anywhere'
});
element.classList.add(CLASSES.wrapActive);
this.updateLineNumbers(pre, code, gutterPre);
// Add resize listener
const resizeHandler = () => {
if (pre.style.whiteSpace === 'pre-wrap') {
this.updateLineNumbers(pre, code, gutterPre);
}
};
window.addEventListener('resize', resizeHandler);
},
updateLineNumbers(pre, code, gutterPre) {
const lines = code.textContent.split('\n');
const tempContainer = Utils.createElement('div');
// Setup temp container for measurement
Object.assign(tempContainer.style, {
visibility: 'hidden',
position: 'absolute',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
overflowWrap: 'anywhere',
font: window.getComputedStyle(code).font,
lineHeight: window.getComputedStyle(code).lineHeight,
width: `${code.getBoundingClientRect().width}px`,
paddingLeft: window.getComputedStyle(pre).paddingLeft,
paddingRight: window.getComputedStyle(pre).paddingRight
});
document.body.appendChild(tempContainer);
let lineNumbersHTML = '';
lines.forEach((line, i) => {
const tempLine = Utils.createElement('div');
tempLine.textContent = line || ' ';
tempContainer.appendChild(tempLine);
const lineHeight = tempLine.offsetHeight;
const singleLineHeight = parseInt(window.getComputedStyle(tempLine).lineHeight, 10);
const lineCount = Math.round(lineHeight / singleLineHeight);
for (let j = 0; j < lineCount; j++) {
lineNumbersHTML += `<span class="line">${j === 0 ? i + 1 : ''}</span><br>`;
}
tempContainer.removeChild(tempLine);
});
document.body.removeChild(tempContainer);
gutterPre.innerHTML = lineNumbersHTML;
}
};
// Main toolbar event handler
function handleToolbarClick(event) {
const target = event.target;
const classList = target.classList;
const handlers = {
'expand': () => FeatureHandlers.shrink(this),
'copy-button': () => FeatureHandlers.copy(this, target),
'raw-button': () => FeatureHandlers.raw(this),
'fa-list-ol': () => FeatureHandlers.toggleLineNumbers(this),
'fa-arrow-down-wide-short': () => FeatureHandlers.toggleWrap(this)
};
for (const [className, handler] of Object.entries(handlers)) {
if (classList.contains(className)) {
handler();
break;
}
}
}
// Expand code handler
function handleExpandCode() {
this.classList.toggle(CLASSES.expandDone);
}
// Toolbar creation
function createToolbar(lang, title, item) {
const fragment = document.createDocumentFragment();
const config = CODE_CONFIG;
// Toolbar is always shown
const toolbar = Utils.createElement('div', `shiki-tools ${config.isHighlightShrink === false ? CLASSES.closed : ''}`);
// Create sections
const leftSection = Utils.createElement('div', 'left');
const centerSection = Utils.createElement('div', 'center');
const rightSection = Utils.createElement('div', 'right');
// Build left section content
let leftHTML = ICONS.trafficLights;
if (config.highlightLang) leftHTML += lang.toUpperCase();
leftSection.innerHTML = leftHTML;
// Build center section
if (config.highlightTitle) centerSection.innerHTML = title;
// Build right section
let rightHTML = '';
if (config.highlightLineNumberToggle) rightHTML += ICONS.lineNumber;
if (config.highlightWrapToggle) rightHTML += ICONS.wrap;
if (config.highlightCopy) rightHTML += ICONS.copy;
if (config.highlightRaw) rightHTML += ICONS.raw;
if (config.isHighlightShrink !== undefined) {
rightHTML += `<i class="fas fa-angle-down expand ${config.isHighlightShrink === false ? CLASSES.closed : ''}"></i>`;
}
rightSection.innerHTML = rightHTML;
// Assemble toolbar
toolbar.appendChild(leftSection);
toolbar.appendChild(centerSection);
toolbar.appendChild(rightSection);
toolbar.addEventListener('click', handleToolbarClick);
fragment.appendChild(toolbar);
// Add expand button if height limit exceeded
if (config.highlightHeightLimit && item.offsetHeight > config.highlightHeightLimit + 30) {
const expandBtn = Utils.createElement('div', 'code-expand-btn', ICONS.expandCode);
expandBtn.addEventListener('click', handleExpandCode);
fragment.appendChild(expandBtn);
}
item.insertBefore(fragment, item.firstChild);
}
// Main initialization function
function addHighlightTool() {
if (!CODE_CONFIG) return;
const figures = document.querySelectorAll(SELECTORS.figureHighlight);
if (!figures.length) return;
figures.forEach(figure => {
// Extract language and title
const classList = figure.getAttribute('class').split(' ');
const lang = classList.length > 1 ? classList[1] : 'PlainText';
const title = figure.getAttribute('data_title') || '';
const langHTML = `<div class="code-lang">${lang}</div>`;
const titleHTML = title ? `<div class="code-title">${title}</div>` : '';
createToolbar(langHTML, titleHTML, figure);
});
}
// Event listeners
document.addEventListener('pjax:success', addHighlightTool);
document.addEventListener('DOMContentLoaded', addHighlightTool);
window.addEventListener('hexo-blog-decrypt', addHighlightTool);