Taken from: [Excalidraw Publish js and css files](https://excalidraw-obsidian.online/WIKI/03%20Advanced%20Usage/Publish%20js%20and%20css%20files)
These files customize an Obsidian Publish site with enhanced SVG handling and a complete dark mode theme. See the [official Obsidian Publish docs](https://help.obsidian.md/publish/customize) for general customization guidance.
> [!Note] Features
> - **Dark mode support**: Automatic dark mode based on system preference, plus manual toggle support via `.theme-dark` class
> - **Excalidraw SVG handling**: Interactive zoom, pan, and enlarge functionality for embedded SVG files
> - **Clean embeds**: Hides markdown embed titles and removes borders for a seamless look
> - **Custom fonts**: Loads Excalidraw's Virgil, Cascadia, and Assistant fonts for diagram rendering
## `publish.css`
```css
@font-face {
font-family: "Virgil";
src: url("https://excalidraw.com/Virgil.woff2");
}
@font-face {
font-family: "Cascadia";
src: url("https://excalidraw.com/Cascadia.woff2");
}
@font-face {
font-family: "Assistant";
src: url("https://excalidraw.com/Assistant-Regular.woff2");
}
:root {
--font-stack: "Assistant", "Cascadia", "Virgil", "Segoe UI", system-ui, sans-serif;
--page-width: min(1024px, 90vw);
--excalidraw-radius: 8px;
--background-primary: #fff6f0;
--background-secondary: #f5ece6;
--background-tertiary: #ede0d8;
--text-normal: #1a1a1a;
--text-muted: #555555;
--text-faint: #707070;
--code-background: #f5f2f0;
--code-border: #ddd;
--inline-code-background: #f0ebe7;
--inline-code-border: #ccc;
--link-color: #2563eb;
--link-color-hover: #1d4ed8;
--hr-color: #d1c8c2;
--blockquote-border: #c4b5ac;
--table-border: #ddd;
--table-header-bg: #f0ebe7;
--table-row-alt-bg: #faf7f5;
}
body {
font-family: var(--font-stack);
margin: 0;
background-color: var(--background-primary);
color: var(--text-normal);
}
div.markdown-embed-title {
display: none;
}
div.markdown-embed {
border: none;
padding: 0;
background-color: inherit;
}
div.excalidraw-svg {
height: 100%;
}
svg.excalidraw-svg {
max-width: 100%;
max-height: 90vh;
width: var(--page-width);
}
svg.excalidraw-svg.ex-pageheight {
width: initial;
height: 100%;
}
svg.excalidraw-svg.ex-pagewidth {
width: 90vw;
height: initial;
}
.excalidraw-svg .text {
width: 100%;
text-align: center;
}
div.excalidraw-svg.enlarged {
position: fixed;
inset: 0;
z-index: 10;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
@media (prefers-color-scheme: dark) {
body:not(.theme-light) {
--background-primary: #1a1a1a;
--background-secondary: #252525;
--background-tertiary: #2e2e2e;
--text-normal: #e0e0e0;
--text-muted: #a0a0a0;
--text-faint: #707070;
--code-background: #2a2a2a;
--code-border: #3a3a3a;
--inline-code-background: #2d2d2d;
--inline-code-border: #444444;
--link-color: #7eb8f7;
--link-color-hover: #a8d1ff;
--hr-color: #3a3a3a;
--blockquote-border: #555555;
--table-border: #3a3a3a;
--table-header-bg: #252525;
--table-row-alt-bg: #222222;
background-color: var(--background-primary);
color: var(--text-normal);
}
}
body.theme-dark {
--background-primary: #1a1a1a;
--background-secondary: #252525;
--background-tertiary: #2e2e2e;
--text-normal: #e0e0e0;
--text-muted: #a0a0a0;
--text-faint: #707070;
--code-background: #2a2a2a;
--code-border: #3a3a3a;
--inline-code-background: #2d2d2d;
--inline-code-border: #444444;
--link-color: #7eb8f7;
--link-color-hover: #a8d1ff;
--hr-color: #3a3a3a;
--blockquote-border: #555555;
--table-border: #3a3a3a;
--table-header-bg: #252525;
--table-row-alt-bg: #222222;
background-color: var(--background-primary);
color: var(--text-normal);
}
:is(body.theme-dark, body:not(.theme-light)) pre,
:is(body.theme-dark, body:not(.theme-light)) pre[class*="language-"] {
background-color: var(--code-background) !important;
border: 1px solid var(--code-border) !important;
color: #d4d4d4 !important;
}
:is(body.theme-dark, body:not(.theme-light)) code:not(pre code) {
background-color: var(--inline-code-background) !important;
border: 1px solid var(--inline-code-border) !important;
color: #c9d1d9 !important;
}
:is(body.theme-dark, body:not(.theme-light)) a {
color: var(--link-color) !important;
}
:is(body.theme-dark, body:not(.theme-light)) a:hover {
color: var(--link-color-hover) !important;
}
:is(body.theme-dark, body:not(.theme-light)) hr {
border-color: var(--hr-color) !important;
}
:is(body.theme-dark, body:not(.theme-light)) blockquote {
border-left-color: var(--blockquote-border) !important;
color: var(--text-muted) !important;
}
:is(body.theme-dark, body:not(.theme-light)) table {
border-color: var(--table-border) !important;
}
:is(body.theme-dark, body:not(.theme-light)) th {
background-color: var(--table-header-bg) !important;
border-color: var(--table-border) !important;
}
:is(body.theme-dark, body:not(.theme-light)) td {
border-color: var(--table-border) !important;
}
:is(body.theme-dark, body:not(.theme-light)) tr:nth-child(even) {
background-color: var(--table-row-alt-bg) !important;
}
:is(body.theme-dark, body:not(.theme-light)) .callout,
:is(body.theme-dark, body:not(.theme-light)) .site-body-left-column {
background-color: var(--background-secondary) !important;
}
:is(body.theme-dark, body:not(.theme-light)) svg.excalidraw-svg {
background-color: var(--background-secondary) !important;
border-radius: var(--excalidraw-radius);
}
:is(body.theme-dark, body:not(.theme-light)) div.excalidraw-svg.enlarged {
background-color: var(--background-primary) !important;
}
:is(body.theme-dark, body:not(.theme-light)) input[type="search"],
:is(body.theme-dark, body:not(.theme-light)) .search-input {
background-color: var(--background-tertiary) !important;
border-color: var(--code-border) !important;
color: var(--text-normal) !important;
}
:is(body.theme-dark, body:not(.theme-light)) .token.string,
:is(body.theme-dark, body:not(.theme-light)) .hljs-string {
color: #ce9178 !important;
}
:is(body.theme-dark, body:not(.theme-light)) .token.keyword,
:is(body.theme-dark, body:not(.theme-light)) .hljs-keyword {
color: #569cd6 !important;
}
:is(body.theme-dark, body:not(.theme-light)) .token.comment,
:is(body.theme-dark, body:not(.theme-light)) .hljs-comment {
color: #6a9955 !important;
}
:is(body.theme-dark, body:not(.theme-light)) .token.number,
:is(body.theme-dark, body:not(.theme-light)) .hljs-number {
color: #b5cea8 !important;
}
:is(body.theme-dark, body:not(.theme-light)) .token.property,
:is(body.theme-dark, body:not(.theme-light)) .hljs-attr {
color: #9cdcfe !important;
}
:is(body.theme-dark, body:not(.theme-light)) svg.excalidraw-svg > rect[fill="#ffffff"],
:is(body.theme-dark, body:not(.theme-light)) svg.excalidraw-svg > rect[fill="#fff"],
:is(body.theme-dark, body:not(.theme-light)) svg.excalidraw-svg > rect[fill="white"] {
fill: var(--background-secondary) !important;
}
:is(body.theme-dark, body:not(.theme-light)) svg.excalidraw-svg [stroke="#1e1e1e"],
:is(body.theme-dark, body:not(.theme-light)) svg.excalidraw-svg [stroke="#000000"],
:is(body.theme-dark, body:not(.theme-light)) svg.excalidraw-svg [stroke="#000"],
:is(body.theme-dark, body:not(.theme-light)) svg.excalidraw-svg [stroke="black"] {
stroke: #e0e0e0 !important;
}
a.site-body-left-column-site-name {
display: none;
}
div.site-footer {
display: none;
}
```
## `publish.js`
This script enables interactive SVG viewing on your publish site. It converts embedded Excalidraw SVG images into interactive elements with zoom, pan, and fullscreen capabilities.
> [!Tip] Controls
> - **Enlarge**: Click and hold for 1 second to toggle fullscreen view
> - **Zoom**: Hold `SHIFT` + scroll wheel
> - **Pan**: Click and drag (when enlarged)
> - **Reset**: Press `ESC` to collapse and reset view
> [!Note] Additional features
> - **Link conversion**: Transforms `obsidian://open?vault=` links into relative site URLs for proper navigation
> - **iframe handling**: Hides site chrome when the page is embedded in an iframe
> - **Device detection**: Adjusts behavior for desktop vs. mobile/tablet devices
```javascript
const clickToEnlarge =
"Click and hold to enlarge. SHIFT + wheel to zoom. ESC to reset.";
const clickToCollapse =
"ESC to reset. Click and hold to collapse. SHIFT + wheel to zoom";
//check if in iFrame - if yes the page is assumed to be an embedded frame
if (window.self !== window.top) {
const elements = [
"div.site-body-right-column",
"div.site-body-left-column",
"div.site-header",
"div.site-footer",
];
elements.forEach((x) => {
document.querySelectorAll(x).forEach((div) => {
div.style.display = "none";
});
});
}
const baseUrl = `${window.location.origin}/`;
const [isDesktop, isMobile, isTablet] = (() => {
const userAgent = navigator.userAgent;
const mobileKeywords = [
"Mobile",
"Android",
"iPhone",
"iPad",
"Windows Phone",
];
const isMobile = mobileKeywords.some((keyword) =>
userAgent.includes(keyword),
);
const isTablet =
/iPad/i.test(userAgent) || (isMobile && !/Mobile/i.test(userAgent));
const isDesktop = !isMobile && !isTablet;
return [isDesktop, isMobile, isTablet];
})();
const addNavigationToDiv = (container) => {
const svgElement = container?.querySelector(".excalidraw-svg");
if (!svgElement) return;
container.addClass("excalidraw-svg");
svgElement.removeAttribute("width");
svgElement.removeAttribute("height");
if (!isDesktop) return;
const textDiv = document.createElement("div");
textDiv.className = "text";
textDiv.textContent = clickToEnlarge;
container.appendChild(textDiv);
let isEnlarged = false;
let timeout = null;
let isReadyToPan = false;
let isPanning = false;
let zoomLevel = 1;
let panX = 0;
let panY = 0;
let pinchStartDistance = 0;
let panStartX = 0;
let panStartY = 0;
const clearEnlargeTimeout = () => {
if (timeout) clearTimeout(timeout);
timeout = null;
};
const enablePointerEvents = (val) => {
svgElement.querySelectorAll("a").forEach((el) => {
el.style.pointerEvents = val ? "all" : "none";
});
};
const applyTransform = () => {
svgElement.style.transform = `scale(${zoomLevel}) translate(${panX}px, ${panY}px)`;
clearEnlargeTimeout();
};
//Wheel zoom
svgElement.addEventListener("wheel", (event) => {
if (!event.shiftKey) return;
if (event.deltaY > 0) {
zoomLevel -=
zoomLevel > 4
? zoomLevel > 6
? zoomLevel > 10
? 0.4
: 0.3
: 0.2
: 0.1;
} else {
zoomLevel +=
zoomLevel > 4
? zoomLevel > 6
? zoomLevel > 10
? 0.4
: 0.3
: 0.2
: 0.1;
}
applyTransform();
});
// Panning
svgElement.addEventListener("mousedown", (event) => {
isReadyToPan = true;
panStartX = event.clientX;
panStartY = event.clientY;
});
svgElement.addEventListener("mousemove", (event) => {
const deltaX = event.clientX - panStartX;
const deltaY = event.clientY - panStartY;
const distance = Math.sqrt(deltaX ** 2 + deltaY ** 2);
if (isReadyToPan && distance > 20) {
if (!isPanning) {
enablePointerEvents(false);
isPanning = true;
}
panX += deltaX / zoomLevel;
panY += deltaY / zoomLevel;
panStartX = event.clientX;
panStartY = event.clientY;
applyTransform();
}
});
svgElement.addEventListener("mouseup", () => {
enablePointerEvents(true);
isPanning = false;
isReadyToPan = false;
});
svgElement.addEventListener("mouseleave", () => {
enablePointerEvents(true);
isPanning = false;
isReadyToPan = false;
});
//abort on Escape
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
enablePointerEvents(true);
isEnlarged = false;
isPanning = false;
isReadyToPan = false;
container.classList.remove("enlarged");
textDiv.textContent = clickToEnlarge;
zoomLevel = 1;
panX = 0;
panY = 0;
applyTransform();
}
});
//Enlarge on long click
svgElement.addEventListener("mouseup", () => clearEnlargeTimeout());
svgElement.addEventListener("mousedown", () => {
timeout = setTimeout(() => {
timeout = null;
if (isPanning) return;
isReadyToPan = false;
if (isEnlarged) {
// Collapse the image
container.classList.remove("enlarged");
textDiv.textContent = clickToEnlarge;
} else {
// Enlarge the image
container.addClass("enlarged");
textDiv.textContent = clickToCollapse;
}
isEnlarged = !isEnlarged;
}, 1000);
});
applyTransform();
};
const processIMG = (img) => {
const svgURL = img.src;
const container = img.parentElement;
fetch(svgURL)
.then((response) => {
if (response.ok) {
return response.text();
}
throw new Error("Failed to fetch SVG");
})
.then((svgContent) => {
svgContainer = document.createElement("div");
svgContainer.innerHTML = svgContent;
svgContainer
.querySelectorAll(`a[href^="obsidian://open?vault="`)
.forEach((el) => {
el.setAttribute(
"href",
unescape(
el
.getAttribute("href")
.replace(/.*&file=/, baseUrl)
.replaceAll(" ", "+"),
),
);
});
svgContainer
.querySelectorAll(`iframe[src^="obsidian://open?vault="`)
.forEach((el) => {
el.setAttribute(
"src",
unescape(
el
.getAttribute("src")
.replace(/.*&file=/, baseUrl)
.replaceAll(" ", "+"),
),
);
});
container.removeChild(img);
container.appendChild(svgContainer);
addNavigationToDiv(svgContainer);
})
.catch((error) => {
console.error("Error: " + error);
});
};
const addImgMutationObserver = () => {
const targetElement = document.body;
const handleImgAddition = (mutationsList, observer) => {
for (const mutation of mutationsList) {
if (mutation.type === "childList") {
mutation.addedNodes.forEach((node) => {
if (
node instanceof Element &&
node.querySelector(`img[alt$=".svg"]`)
) {
processIMG(node.querySelector(`img[alt$=".svg"]`));
}
});
}
}
};
const observer = new MutationObserver(handleImgAddition);
const config = { childList: true, subtree: true };
observer.observe(targetElement, config);
};
//process images after loading
document.body.querySelectorAll(`img[alt$=".svg"`).forEach((img) => {
processIMG(img);
});
addImgMutationObserver();
```