fuzzyworld

tools / commit

Initial rewrite of separator page.

f86ea817ad7537dd61a6b0825719069c055a7d65 · Pavel Safronov · 2025-07-21 21:21:51 -0700 · parents 3f3d83365713

Initial rewrite of separator page.
 separator-page/index.html | 695 +++++++++++++++++++++++++++-------------------
 1 file changed, 410 insertions(+), 285 deletions(-)

diff --git a/separator-page/index.html b/separator-page/index.html
index c72df94..60f4c6f 100644
--- a/separator-page/index.html
+++ b/separator-page/index.html
@@ -1,320 +1,445 @@
 <!DOCTYPE html>
 <html>
 
-    <head>
-        <meta charset="utf-8" />
-        <title>Separator</title>
-        <link rel="icon"
-              href="data:image/x-icon;base64,AAABAAEAEBAQAAEABAAoAQAAFgAAACgAAAAQAAAAIAAAAAEABAAAAAAAgAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAA//8AAP//AAD//wAA/n8AAP5/AAD+fwAA/n8AAOAHAADgBwAA/n8AAP5/AAD+fwAA/n8AAP//AAD//wAA" />
-        <script language="javascript"
-                src="https://pavelsafronov.gitlab.io/tools/separator-page/lz-string.js"></script>
-
-        <!-- https://github.com/showdownjs/showdown -->
-        <script language="javascript"
-                src="https://rawgit.com/showdownjs/showdown/develop/dist/showdown.min.js"></script>
-        <script>
-            /** @typedef {Object} State
-             * @property {string} name
-             * @property {string} description
-             */
-
-            /** @type {State} */
-            const defaultState = {
-                name: "Separator",
-                description: `# Info
-
-This is a separator page!
-
-## TODO
-
-1. Read this page
-1. Understand this page
-1. The page name and page content are both editable
-1. To enter "edit mode", click on the "Edit" button OR **double-click on title or description element**
-1. Double-click on either element again to return to "view mode"
-1. Replace the contents on this page to whatever you want a new separator page to contain
-1. Markdown contents will be rendered as HTML
-1. Content links will always open in a new tab
-1. Click "Save as Default" button to save current page as default template
-1. If you messed up this page, click on "Hard Reset" to go back to this default message
-1. The name and the description are stored as zipped content in the url, so copy-pasting the url in a new tab will exactly reproduce this page
-1. Your data is stored in the hash fragment of the url, and [this data is not sent to the server](https://stackoverflow.com/questions/14462218/is-the-url-fragment-identifier-sent-to-the-server), so your notes are never sent anywhere
-1. But your notes **do** end up in the browser's history, so don't keep anything very important here
-1. The default page state (default title and contents) is kept in [local storage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage), so this information also never leaves your computer
-1. Local storage is accessible by any other script that's executing on the separator page (or on the same domain), so technically *some* scripts can get your local "default separator page" state`
-            };
-            const maxDataLength = 64 * 1024;
-            let currentState = defaultState;
-
-            /** @type {HTMLElement} */
-            let nameSpan;
-            /** @type {HTMLInputElement} */
-            let nameInput;
-            /** @type {HTMLElement} */
-            let descriptionSpan;
-            /** @type {HTMLElement} */
-            let descriptionShowSpan;
-
-            const lsKey = "pavel-safronov-separator-page";
-            const storedDefaultJson = localStorage.getItem(lsKey);
-            if (isNullOrEmpty(storedDefaultJson)) {
-                localStorage.setItem(lsKey, JSON.stringify(currentState));
-            } else {
-                /** @type {State} */
-                let newState;
-                try {
-                    newState = JSON.parse(storedDefaultJson);
-                } catch {
-                    console.error({ message: "Could not parse stored default", storedDefaultJson });
-                }
-                if (newState) {
-                    currentState = newState;
-                }
-            }
+<head>
+    <meta charset="utf-8" />
+    <title>Separator</title>
+    <link rel="icon" id="favicon"
+        href="data:image/x-icon;base64,AAABAAEAEBAQAAEABAAoAQAAFgAAACgAAAAQAAAAIAAAAAEABAAAAAAAgAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAA//8AAP//AAD//wAA/n8AAP5/AAD+fwAA/n8AAOAHAADgBwAA/n8AAP5/AAD+fwAA/n8AAP//AAD//wAA" />
+    <script src="https://cdn.jsdelivr.net/npm/showdown/dist/showdown.min.js"></script>
+    <style>
+        body {
+            font-family: Arial, sans-serif;
+            margin: 0;
+            padding: 0;
+            display: flex;
+            flex-direction: column;
+            align-items: center;
+            justify-content: center;
+            height: 100vh;
+            background-color: #f9f9f9;
+        }
+
+        .container {
+            width: 90%;
+            max-width: 600px;
+            background: #fff;
+            border: 1px solid #ddd;
+            border-radius: 8px;
+            box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
+            padding: 16px;
+        }
+
+        .header {
+            display: flex;
+            align-items: center;
+            gap: 12px;
+            margin-bottom: 8px;
+        }
+
+        .icon-preview {
+            width: 32px;
+            height: 32px;
+            border: 1px solid #ddd;
+            border-radius: 4px;
+            overflow: hidden;
+            background-size: cover;
+        }
+
+        .title {
+            font-size: 1.5em;
+            font-weight: bold;
+            width: 100%;
+            outline: none;
+            border: none;
+            background: none;
+        }
+
+        .title[contenteditable="true"] {
+            border: 1px dashed #aaa;
+            padding: 4px;
+            border-radius: 4px;
+        }
+
+        .description-container {
+            position: relative;
+        }
+
+        .description {
+            font-size: 1em;
+            line-height: 1.5;
+            color: #555;
+            outline: none;
+            width: 100%;
+            display: none;
+            height: max-content;
+        }
+
+        .description-rendered {
+            font-size: 1em;
+            line-height: 1.5;
+            color: #555;
+            width: 100%;
+        }
+
+        .description[contenteditable="true"] {
+            border: 1px dashed #aaa;
+            padding: 8px;
+            border-radius: 4px;
+            display: block;
+        }
+
+        .toolbar {
+            margin-top: 16px;
+            display: flex;
+            flex-direction: column;
+            gap: 8px;
+        }
+
+        .info {
+            font-size: 0.9em;
+            color: #666;
+            text-align: center;
+            margin-top: 12px;
+        }
+
+        .drop-area {
+            border: 2px dashed #aaa;
+            padding: 8px;
+            text-align: center;
+            color: #666;
+            font-size: 0.9em;
+            border-radius: 4px;
+        }
+
+        .drop-area.dragover {
+            background-color: #eef;
+            border-color: #666;
+        }
+
+        .custom-icon-input {
+            display: flex;
+            gap: 8px;
+            align-items: center;
+        }
+
+        .custom-icon-input input {
+            flex-grow: 1;
+        }
+
+        .edit-button {
+            position: absolute;
+            top: 0;
+            right: 0;
+            background: #ddd;
+            border: none;
+            padding: 4px;
+            cursor: pointer;
+            border-radius: 4px;
+        }
+
+        .edit-button:hover {
+            background: #ccc;
+        }
+
+        .previous-icons {
+            display: flex;
+            flex-wrap: wrap;
+            gap: 8px;
+            margin-top: 8px;
+        }
+
+        .previous-icon {
+            width: 32px;
+            height: 32px;
+            border: 1px solid #ddd;
+            border-radius: 4px;
+            cursor: pointer;
+            background-size: cover;
+            background-position: center;
+            transition: transform 0.2s;
+        }
+
+        .previous-icon:hover {
+            transform: scale(1.1);
+            border-color: #999;
+        }
+
+        .previous-icons-container {
+            margin: 16px 0;
+        }
+
+        .previous-icons-container h3 {
+            margin: 0 0 8px 0;
+            font-size: 1.1em;
+            color: #333;
+        }
+    </style>
+</head>
+
+<body>
+
+    <div class="container">
+        <div class="header">
+            <div id="icon-preview" class="icon-preview" title="Icon Preview"></div>
+            <div id="title" class="title" contenteditable="true">Separator</div>
+        </div>
 
-            // MD converter
-            var shodownConverter = new showdown.Converter();
+        <hr />
 
-            /**
-             * @param {string} text
-             */
-            function isNullOrEmpty(text) {
-                return (!text || text.length === 0);
-            }
+        <div class="description-container">
+            <textarea id="description" class="description" placeholder="Write your Markdown here..."># Info
 
-            /**
-             * @param {State} partialState
-             */
-            function update(partialState) {
-                const path = window.location.pathname;
-
-                if (partialState) {
-                    const newState = {
-                        ...currentState,
-                        ...partialState
-                    };
-                    currentState = newState;
-                }
-                const stateJson = JSON.stringify(currentState);
-                const stateCompressed = LZString.compressToBase64(stateJson);
-                const newPath = `${path}#${stateCompressed}`;
-                if (newPath.length >= maxDataLength) {
-                    alert(`Url is too long (${newPath.length} characters), need to rethink storing data in the hash. Or not using the Separator Page as a OneNote replacement.`);
-                }
+This is a separator page!</textarea>
+            <div id="description-rendered" class="description-rendered"></div>
+            <button id="edit-description" class="edit-button">Edit</button>
+        </div>
 
-                try {
-                    history.replaceState(currentState, "", newPath);
-                } catch (ex) {
-                    alert(`Not able to save contents: ${ex}`);
-                }
+        <hr />
 
-                let { name, description } = currentState;
-                // set name
-                document.title = isNullOrEmpty(name) ? "---" : name;
-                if (nameSpan.innerHTML !== name) {
-                    nameSpan.innerHTML = name;
-                }
-                if (nameInput.value !== name) {
-                    nameInput.value = name;
-                }
+        <div class="previous-icons-container">
+            <h3>Previous Icons (shift-click to remove)</h3>
+            <div class="previous-icons" id="previous-icons">
+                <!-- Previous icons will be dynamically added here -->
+            </div>
+        </div>
 
-                // set description
-                const descriptionToUse = description !== null && description.length > 0 ? description : "**No content specified, double-click to edit**";
-                descriptionShowSpan.innerHTML = shodownConverter.makeHtml(descriptionToUse);
-                descriptionSpan.value = isNullOrEmpty(description) ? "" : description;
+        <div class="toolbar">
+            <div class="drop-area" id="drop-area">Drop a PNG here or paste an image URL</div>
+            <div class="custom-icon-input">
+                <input id="icon-url-input" type="text" placeholder="Paste an image URL here" />
+                <button id="apply-icon-button">Apply</button>
+            </div>
+        </div>
 
-                // update all href links to open in new tabs
-                const links = descriptionShowSpan.getElementsByTagName("a");
-                for (let i = 0; i < links.length; i++) {
-                    const link = links.item(i);
-                    link.target = "_blank";
-                }
-            }
+        <div class="info">
+            Need icons? Check out <a href="https://flaticon.com" target="_blank">Flaticon</a>.
+            <br />
+            The default icons came from <a href="https://www.flaticon.com/authors/icongeek26"
+                target="_blank">icongeek26</a>
+        </div>
+    </div>
 
-            /**
-             * @param {HTMLElement} a
-             * @param {HTMLElement} b
-            */
-            function flipHideShow(a, b) {
-                const isAHidden = a.className === "hidden";
-                a.className = isAHidden ? "" : "hidden";
-                b.className = isAHidden ? "hidden" : "";
-            }
+    <script>
+        const converter = new showdown.Converter();
 
-            window.onpopstate = (state) => {
-                update(state);
-            };
-            window.onload = () => {
-                /** @type {State} */
-                let state = history.state;
-
-                const search = window.location.search;
-                if (search) {
-                    const urlParams = new URLSearchParams(search);
-                    const name = urlParams.get("name");
-                    if (name) {
-                        state = { name };
-                    }
-                }
+        /** @type {string} */
+        const tabId = new URLSearchParams(window.location.search).get("tab") || Math.floor(Date.now() / 1000).toString();
 
-                const hash = window.location.hash;
-                if (hash) {
-                    const compressed = hash.substring(1);
-                    var json = LZString.decompressFromBase64(compressed);
-                    state = JSON.parse(json);
-                }
+        /** @type {string} */
+        const localStorageKey = `separator-tab-${tabId}`;
 
-                // name input
-                nameInput = document.getElementById("name-input");
-                nameInput.onkeyup = () => update({ name: nameInput.value });
-
-                // name field
-                nameSpan = document.getElementById("name");
-
-                // description field
-                descriptionSpan = document.getElementById("description");
-                descriptionSpan.onkeyup = () => update({ description: descriptionSpan.value });
-
-                // description html field
-                descriptionShowSpan = document.getElementById("description-show");
-
-                const saveButton = document.getElementById("save");
-                const resetButton = document.getElementById("reset");
-                const editButton = document.getElementById("edit");
-                const doneButton = document.getElementById("done");
-
-                // hook up double-click switching
-                const flipEditState = () => {
-                    flipHideShow(descriptionShowSpan, descriptionSpan);
-                    flipHideShow(nameInput, nameSpan);
-                    flipHideShow(editButton, doneButton);
-                };
-                descriptionShowSpan.addEventListener("dblclick", flipEditState);
-                descriptionSpan.addEventListener("dblclick", flipEditState);
-                nameInput.addEventListener("dblclick", () => flipEditState);
-                nameSpan.addEventListener("dblclick", () => flipEditState);
-
-                saveButton.onclick = () => {
-                    if (confirm("Save current title and description as default starting values for separator page?")) {
-                        localStorage.setItem(lsKey, JSON.stringify(currentState));
-                    }
-                };
-                resetButton.onclick = () => {
-                    if (confirm("Set default title and description values to original values?")) {
-                        localStorage.setItem(lsKey, JSON.stringify(defaultState));
-                        update(defaultState);
-                    }
-                };
-                editButton.onclick = () => {
-                    flipEditState();
-                }
-                done.onclick = () => {
-                    flipEditState();
-                }
+        /** @type {HTMLElement} */
+        const iconPreview = document.getElementById("icon-preview");
 
-                // render everything for the first time
-                update(state);
-            };
+        /** @type {HTMLElement} */
+        const titleElement = document.getElementById("title");
 
-        </script>
-        <style>
-            #description {
-                color: gray;
-                font-size: small;
-                width: 100%;
-                height: 100%;
-                box-sizing: border-box;
-            }
+        /** @type {HTMLTextAreaElement} */
+        const descriptionElement = document.getElementById("description");
 
-            #name {
-                width: 100%;
-                font-size: xx-large;
-            }
+        /** @type {HTMLElement} */
+        const descriptionRendered = document.getElementById("description-rendered");
 
-            .container {
-                height: 100%;
-            }
+        /** @type {HTMLButtonElement} */
+        const editDescriptionButton = document.getElementById("edit-description");
 
-            .info {
-                color: gray;
-                font-size: small;
-            }
+        /** @type {HTMLInputElement} */
+        const iconUrlInput = document.getElementById("icon-url-input");
 
-            .left {
-                float: left;
-            }
+        /** @type {HTMLButtonElement} */
+        const applyIconButton = document.getElementById("apply-icon-button");
 
-            .right {
-                float: right;
-            }
+        /** @type {HTMLDivElement} */
+        const dropArea = document.getElementById("drop-area");
 
-            .hidden {
-                display: none;
-            }
+        /** @type {HTMLLinkElement} */
+        const faviconElement = document.getElementById("favicon");
 
-        </style>
-    </head>
+        /** @type {HTMLDivElement} */
+        const previousIconsContainer = document.getElementById("previous-icons");
 
-    <body>
+        // Global storage key for previous icons (shared across all tabs)
+        const previousIconsKey = "separator-previous-icons";
+        const maxPreviousIcons = 100; // Limit to 100 recent icons
 
-        <div class="container">
+        // Load saved data
+        const savedData = JSON.parse(localStorage.getItem(localStorageKey) || "null");
 
-            <div class="container">
+        if (!new URLSearchParams(window.location.search).has("tab")) {
+            const newUrl = `${window.location.origin}${window.location.pathname}?tab=${tabId}`;
+            history.replaceState(null, "", newUrl);
+        }
 
-                <input autofocus
-                       id="name-input"
-                       title="Name"
-                       class="hidden"
-                       size="100" />
+        if (savedData) {
+            renderAll(savedData);
+            console.debug('Loaded saved data:', savedData);
+        } else {
+            saveTabData();
+        }
 
-                <span id="name"
-                      title="Tab title - double-click to edit">
-                    Separator
-                </span>
+        // Initialize previous icons display
+        renderPreviousIcons();
 
-                <span class="info">
-                    Tab title
-                </span>
-            </div>
+        function updateFavicon(iconUrl) {
+            console.debug('Updating favicon with URL:', iconUrl);
+            faviconElement.href = iconUrl;
+        }
 
-            <div class="container">
+        function savePreviousIcon(iconUrl) {
+            // Skip default favicon and data URLs for data:image/x-icon
+            if (iconUrl.startsWith('data:image/x-icon')) {
+                return;
+            }
 
-                <textarea rows="30"
-                          id="description"
-                          title="content"
-                          class="hidden"></textarea>
+            let previousIcons = JSON.parse(localStorage.getItem(previousIconsKey) || "[]");
 
-                <span id="description-show"
-                      title="Description - double-click to edit"></span>
-            </div>
-        </div>
+            // Remove the icon if it already exists to avoid duplicates
+            previousIcons = previousIcons.filter(icon => icon !== iconUrl);
 
-        <div class="container">
-
-            <div class="left">
-                <button type="button"
-                        id="edit">
-                    Edit
-                </button>
-                <button type="button"
-                        id="done"
-                        class="hidden">
-                    Done
-                </button>
-                <button type="button"
-                        id="save">
-                    Save as Default
-                </button>
-            </div>
+            // Add the new icon to the beginning
+            previousIcons.unshift(iconUrl);
 
-            <div class="right">
-                <button type="button"
-                        id="reset">
-                    Hard Reset
-                </button>
-            </div>
-        </div>
-        </div>
-    </body>
+            // Limit to maxPreviousIcons
+            if (previousIcons.length > maxPreviousIcons) {
+                previousIcons = previousIcons.slice(0, maxPreviousIcons);
+            }
+
+            localStorage.setItem(previousIconsKey, JSON.stringify(previousIcons));
+            renderPreviousIcons();
+        }
+
+        function removePreviousIcon(iconUrl) {
+            let previousIcons = JSON.parse(localStorage.getItem(previousIconsKey) || "[]");
+            previousIcons = previousIcons.filter(icon => icon !== iconUrl);
+            localStorage.setItem(previousIconsKey, JSON.stringify(previousIcons));
+            renderPreviousIcons();
+        }
+
+        function renderPreviousIcons() {
+            const previousIcons = JSON.parse(localStorage.getItem(previousIconsKey) || "[]");
+            previousIconsContainer.innerHTML = "";
+
+            previousIcons.forEach(iconUrl => {
+                const iconElement = document.createElement("div");
+                iconElement.className = "previous-icon";
+                iconElement.style.backgroundImage = `url('${iconUrl}')`;
+                iconElement.title = `${iconUrl}\n\nClick to use • Shift+Click to remove`;
+                iconElement.addEventListener("click", (event) => {
+                    if (event.shiftKey) {
+                        removePreviousIcon(iconUrl);
+                    } else {
+                        handleIconChange(iconUrl);
+                    }
+                });
+                previousIconsContainer.appendChild(iconElement);
+            });
+        }
+
+        function saveTabData() {
+            const data = {
+                id: tabId,
+                icon: faviconElement.href,
+                title: titleElement.innerText,
+                description: descriptionElement.value,
+            };
+            console.debug('Saving tab data to localStorage:', data);
+            renderAll(data);
+            localStorage.setItem(localStorageKey, JSON.stringify(data));
+        }
+
+        function renderAll(data) {
+            iconPreview.style.backgroundImage = `url('${data.icon}')`;
+            if (titleElement.innerText !== data.title) {
+                titleElement.innerText = data.title;
+            }
+            document.title = titleElement.innerText || "Separator";
+            renderDescription(data.description);
+            updateFavicon(data.icon);
+        }
+
+        function renderDescription(description) {
+            descriptionElement.value = description;
+            descriptionElement.style.height = "";
+            descriptionElement.style.height = `${descriptionElement.scrollHeight}px`;
+            const html = converter.makeHtml(description);
+            descriptionRendered.innerHTML = html;
+        }
+
+        function handleIconChange(iconUrl) {
+            iconPreview.style.backgroundImage = `url('${iconUrl}')`;
+            updateFavicon(iconUrl);
+            savePreviousIcon(iconUrl);
+            saveTabData();
+        }
+
+        // Toggle edit mode for description
+        editDescriptionButton.addEventListener("click", () => {
+            const isEditing = descriptionElement.style.display === "block";
+
+            console.debug('Toggling edit mode:', isEditing ? 'Done' : 'Edit');
+            descriptionElement.style.display = isEditing ? "none" : "block";
+            descriptionRendered.style.display = isEditing ? "block" : "none";
+            editDescriptionButton.innerText = isEditing ? "Edit" : "Done";
+
+            // renderDescription();
+            saveTabData();
+        });
+
+        // Drop area handling
+        dropArea.addEventListener("dragover", (event) => {
+            event.preventDefault();
+            dropArea.classList.add("dragover");
+        });
+
+        dropArea.addEventListener("dragleave", () => {
+            dropArea.classList.remove("dragover");
+        });
+
+        dropArea.addEventListener("drop", (event) => {
+            event.preventDefault();
+            dropArea.classList.remove("dragover");
+            const file = event.dataTransfer.files[0];
+            if (file && file.type.startsWith("image/")) {
+                const reader = new FileReader();
+                reader.onload = () => handleIconChange(reader.result);
+                reader.readAsDataURL(file);
+            }
+        });
+
+        // Clipboard paste handling
+        document.addEventListener("paste", (event) => {
+            const clipboardItems = event.clipboardData.items;
+            for (const item of clipboardItems) {
+                if (item.type.startsWith("image/")) {
+                    const blob = item.getAsFile();
+                    const reader = new FileReader();
+                    reader.onload = () => handleIconChange(reader.result);
+                    reader.readAsDataURL(blob);
+                    break;
+                }
+            }
+        });
+
+        // Apply icon from URL
+        applyIconButton.addEventListener("click", () => {
+            const iconUrl = iconUrlInput.value.trim();
+            if (iconUrl) {
+                handleIconChange(iconUrl);
+            }
+        });
+
+        // Title and description event listeners
+        titleElement.addEventListener("input", () => {
+            console.debug('Title updated to:', titleElement.innerText);
+            saveTabData();
+        });
+
+        descriptionElement.addEventListener("input", () => {
+            // console.debug('Description updated to:', descriptionElement.value);
+        });
+    </script>
+
+</body>
 
-</html>
+</html>
\ No newline at end of file