From e774e551b50af7dfa320adb5e9b5cc7f790f0e52 Mon Sep 17 00:00:00 2001 From: Hsieh Chin Fan Date: Mon, 21 Oct 2024 20:50:14 +0800 Subject: refactor: use MuationObserver to update contents BREAKING CHANGE: * generateMaps no more called when content changes --- index.html | 4 +- src/dumbyUtils.mjs | 19 ++-- src/dumbymap.mjs | 265 ++++++++++++++++++++++++++++++----------------------- src/editor.mjs | 27 +++--- src/utils.mjs | 21 +++-- 5 files changed, 192 insertions(+), 144 deletions(-) diff --git a/index.html b/index.html index 6ddcc8a..59cf7ce 100644 --- a/index.html +++ b/index.html @@ -29,7 +29,9 @@
-
+
+
+
diff --git a/src/dumbyUtils.mjs b/src/dumbyUtils.mjs index 46594bd..0430f97 100644 --- a/src/dumbyUtils.mjs +++ b/src/dumbyUtils.mjs @@ -143,7 +143,7 @@ const addLeaderLine = (link, target) => { /** * Create geolinks, which points to map by geo schema and id * - * @param {HTMLElement} Elements contains anchor elements for doclinks + * @param {HTMLElement} Elements contains anchor elements for GeoLinks * @returns {Boolean} ture is link is created, false if coordinates are invalid */ export const createGeoLink = (link) => { @@ -164,6 +164,7 @@ export const createGeoLink = (link) => { link.dataset.lat = lat link.dataset.crs = params.get('crs') link.classList.add('with-leader-line', 'geolink') + link.classList.remove('not-geolink') // TODO refactor as data attribute link.targets = params.get('id')?.split(',') ?? null link.type = params.get('type') ?? null @@ -363,10 +364,8 @@ export const setGeoSchemeByCRS = (crs) => (link) => { link.search = params const unit = proj4(crs).oProj.units - const invalidDegree = unit === 'degrees' && ( - (lon > 180 || lon < -180 || lat > 90 || lat < -90) || - (xy.every(v => v.toString().length < 3)) - ) + const invalidDegree = unit === 'degrees' && + (lon > 180 || lon < -180 || lat > 90 || lat < -90) const invalidMeter = unit === 'm' && xy.find(v => v < 100) if (invalidDegree || invalidMeter) { link.replaceWith(document.createTextNode(link.textContent)) @@ -443,9 +442,15 @@ export const dragForAnchor = (container, range, endOfLeaderLine) => { export const addGeoSchemeByText = async (element) => { const coordPatterns = /(-?\d+\.?\d*)([,\x2F\uFF0C])(-?\d+\.?\d*)/ const re = new RegExp(coordPatterns, 'g') - replaceTextNodes(element, re, match => { + + return replaceTextNodes(element, re, match => { + const [x, y] = [match.at(1), match.at(3)] + // Don't process string which can be used as date + if (Date.parse(match.at(0) + ' 1990')) return null + const a = document.createElement('a') - a.href = `geo:0,0?xy=${match.at(1)},${match.at(3)}` + a.className = 'not-geolink' + a.href = `geo:0,0?xy=${x},${y}` a.textContent = match.at(0) return a }) diff --git a/src/dumbymap.mjs b/src/dumbymap.mjs index 97c13af..9a6979b 100644 --- a/src/dumbymap.mjs +++ b/src/dumbymap.mjs @@ -14,8 +14,8 @@ import { register, fromEPSGCode } from 'ol/proj/proj4' /** CSS Selector for main components */ const mapBlockSelector = 'pre:has(.language-map), .mapclay-container' -const docLinkSelector = 'a[href^="#"][title^="=>"]' -const geoLinkSelector = 'a[href^="geo:"]' +const docLinkSelector = 'a[href^="#"][title^="=>"]:not(.doclink)' +const geoLinkSelector = 'a[href^="geo:"]:not(.geolink)' /** Default Layouts */ const defaultLayouts = [ @@ -137,31 +137,134 @@ export const generateMaps = (container, { layouts = [], delay, renderCallback, - autoMap = false, + contentSelector, render = defaultRender, } = {}) => { /** Prepare Contaner */ container.classList.add('Dumby') delete container.dataset.layout + container.dataset.crs = crs + register(proj4) /** Prepare Semantic HTML part and blocks of contents inside */ - const htmlHolder = container.querySelector('.SemanticHtml, main, :scope > article') ?? + const htmlHolder = container.querySelector(contentSelector) ?? + container.querySelector('.SemanticHtml, main, :scope > article') ?? Array.from(container.children).find(e => e.id?.match(/main|content/) || e.className?.match?.(/main|content/)) ?? Array.from(container.children).sort((a, b) => a.textContent.length < b.textContent.length).at(0) htmlHolder.classList.add('SemanticHtml') - const blocks = htmlHolder.querySelectorAll('.dumby-block') - blocks.forEach(b => { - b.dataset.total = blocks.length - }) - /** Prepare Showcase */ const showcase = document.createElement('div') container.appendChild(showcase) showcase.classList.add('Showcase') + /** WATCH: content of Semantic HTML */ + const contentObserver = new window.MutationObserver((mutations) => { + const mutation = mutations.at(-1) + const addedNodes = Array.from(mutation.addedNodes) + const removedNodes = Array.from(mutation.removedNodes) + if ( + addedNodes.length === 0 || + [...addedNodes, ...removedNodes].find(node => node.classList?.contains('not-geolink')) + ) return + + // Update dumby block + if ([...addedNodes, ...removedNodes].find(node => node.classList?.contains('dumby-block'))) { + const blocks = container.querySelectorAll('.dumby-block') + blocks.forEach(b => { + b.dataset.total = blocks.length + }) + } + + // Add GeoLinks/DocLinks by pattern + addedNodes.forEach((node) => { + if (!(node instanceof window.HTMLElement)) return + + const linksWithGeoScheme = node.matches(geoLinkSelector) + ? [node] + : Array.from(node.querySelectorAll(geoLinkSelector)) + linksWithGeoScheme.forEach(utils.createGeoLink) + + const linksWithDocPattern = node.matches(docLinkSelector) + ? [node] + : Array.from(node.querySelectorAll(docLinkSelector)) + linksWithDocPattern.forEach(utils.createDocLink) + + // Render each code block with "language-map" class + const mapTargets = node.matches(mapBlockSelector) + ? [node] + : Array.from(node.querySelectorAll(mapBlockSelector)) + mapTargets.forEach(renderMap) + }) + + // Add GeoLinks from plain texts + const addGeoScheme = addedNodes.map(utils.addGeoSchemeByText) + const crsString = container.dataset.crs + Promise.all([fromEPSGCode(crsString), ...addGeoScheme]).then((values) => { + values.slice(1) + .flat() + .map(utils.setGeoSchemeByCRS(crsString)) + .filter(link => link) + .forEach(utils.createGeoLink) + }) + + // Remov all leader lines + htmlHolder.querySelectorAll('.with-leader-line') + .forEach(utils.removeLeaderLines) + }) + + contentObserver.observe(container, { + childList: true, + subtree: true, + }) + + /** WATCH: Layout changes */ + const layoutObserver = new window.MutationObserver(mutations => { + const mutation = mutations.at(-1) + const oldLayout = mutation.oldValue + const newLayout = container.dataset.layout + + // Apply handler for leaving/entering layouts + if (oldLayout) { + dumbymap.layouts + .find(l => l.name === oldLayout) + ?.leaveHandler?.call(this, dumbymap) + } + + Object.values(dumbymap) + .flat() + .filter(ele => ele instanceof window.HTMLElement) + .forEach(ele => { ele.style.cssText = '' }) + + if (newLayout) { + dumbymap.layouts + .find(l => l.name === newLayout) + ?.enterHandler?.call(this, dumbymap) + } + + // Since layout change may show/hide showcase, the current focused map may need to go into/outside showcase + // Reset attribute triggers MutationObserver which is observing it + const focusMap = + container.querySelector('.mapclay.focus') ?? + container.querySelector('.mapclay') + focusMap?.classList?.add('focus') + }) + + layoutObserver.observe(container, { + attributes: true, + attributeFilter: ['data-layout'], + attributeOldValue: true, + }) + + container.dataset.layout = initialLayout ?? defaultLayouts[0].name + + /** WATCH: Disconnect observers when container is removed */ + onRemove(container, () => { + contentObserver.disconnect() + layoutObserver.disconnect() + }) + /** Prepare Other Variables */ - const renderPromises = [] const modalContent = document.createElement('div') container.appendChild(modalContent) const modal = new PlainModal(modalContent) @@ -172,7 +275,7 @@ export const generateMaps = (container, { container, htmlHolder, showcase, - get blocks () { return Array.from(htmlHolder.querySelectorAll('.dumby-block')) }, + get blocks () { return Array.from(container.querySelectorAll('.dumby-block')) }, modal, modalContent, utils: { @@ -202,39 +305,8 @@ export const generateMaps = (container, { } }) - /** LINK: Create DocLinks */ - container.querySelectorAll(docLinkSelector) - .forEach(utils.createDocLink) - - /** LINK: Add external symbol on anchors */ - container.querySelectorAll('a') - .forEach(a => { - if (typeof a.href === 'string' && a.href.startsWith('http') && !a.href.startsWith(window.location.origin)) { - a.classList.add('external') - } - }) - - /** LINK: Set CRS and GeoLinks */ - const setCRS = (async () => { - register(proj4) - await fromEPSGCode(crs) - })() - const addGeoScheme = utils.addGeoSchemeByText(htmlHolder) - - Promise.all([setCRS, addGeoScheme]).then(() => { - Array.from(container.querySelectorAll(geoLinkSelector)) - .map(utils.setGeoSchemeByCRS(crs)) - .filter(link => link instanceof window.HTMLAnchorElement) - .forEach(utils.createGeoLink) - }) - - /** LINK: remove all leaderline when onRemove() */ - onRemove(htmlHolder, () => - htmlHolder.querySelectorAll('.with-leader-line') - .forEach(utils.removeLeaderLines), - ) /** - * mapFocusObserver. observe for map focus + * MAP: mapFocusObserver. observe for map focus * @return {MutationObserver} observer */ const mapClassObserver = () => @@ -310,49 +382,8 @@ export const generateMaps = (container, { } }) - /** Observer for layout changes */ - const layoutObserver = new window.MutationObserver(mutations => { - const mutation = mutations.at(-1) - const oldLayout = mutation.oldValue - const newLayout = container.dataset.layout - - // Apply handler for leaving/entering layouts - if (oldLayout) { - dumbymap.layouts - .find(l => l.name === oldLayout) - ?.leaveHandler?.call(this, dumbymap) - } - - Object.values(dumbymap) - .flat() - .filter(ele => ele instanceof window.HTMLElement) - .forEach(ele => { ele.style.cssText = '' }) - - if (newLayout) { - dumbymap.layouts - .find(l => l.name === newLayout) - ?.enterHandler?.call(this, dumbymap) - } - - // Since layout change may show/hide showcase, the current focused map may need to go into/outside showcase - // Reset attribute triggers MutationObserver which is observing it - const focusMap = - container.querySelector('.mapclay.focus') ?? - container.querySelector('.mapclay') - focusMap?.classList?.add('focus') - }) - layoutObserver.observe(container, { - attributes: true, - attributeFilter: ['data-layout'], - attributeOldValue: true, - characterDataOldValue: true, - }) - - onRemove(htmlHolder, () => layoutObserver.disconnect()) - container.dataset.layout = initialLayout ?? defaultLayouts[0].name - /** - * afterMapRendered. callback of each map rendered + * MAP: afterMapRendered. callback of each map rendered * * @param {Object} renderer */ @@ -380,7 +411,7 @@ export const generateMaps = (container, { attributeFilter: ['class'], attributeOldValue: true, }) - onRemove(dumbymap.htmlHolder, () => { + onRemove(mapElement.closest('.SemanticHtml'), () => { observer.disconnect() }) @@ -394,8 +425,10 @@ export const generateMaps = (container, { } // Set unique ID for map container - const mapIdList = [] - const assignMapId = config => { + function assignMapId (config) { + const mapIdList = Array.from(document.querySelectorAll('.mapclay')) + .map(map => map.id) + .filter(id => id) let mapId = config.id?.replaceAll('\x20', '_') if (!mapId) { mapId = config.use?.split('/')?.at(-1) @@ -410,23 +443,24 @@ export const generateMaps = (container, { mapIdList.push(mapId) return config } + // + // if (autoMap && elementsWithMapConfig.length === 0) { + // const mapContainer = document.createElement('pre') + // mapContainer.className = 'mapclay-container' + // mapContainer.textContent = '#Created by DumbyMap' + // mapContainer.style.cssText = 'display: none;' + // htmlHolder.insertBefore(mapContainer, htmlHolder.firstElementChild) + // elementsWithMapConfig.push(mapContainer) + // } + // - // Render each code block with "language-map" class - const elementsWithMapConfig = Array.from( - container.querySelectorAll(mapBlockSelector) ?? [], - ) - if (autoMap && elementsWithMapConfig.length === 0) { - const mapContainer = document.createElement('pre') - mapContainer.className = 'mapclay-container' - mapContainer.textContent = '#Created by DumbyMap' - mapContainer.style.cssText = 'display: none;' - htmlHolder.insertBefore(mapContainer, htmlHolder.firstElementChild) - elementsWithMapConfig.push(mapContainer) - } - - /** Render each taget element for maps */ let order = 0 - elementsWithMapConfig.forEach(target => { + /** + * MAP: Render each taget element for maps + * + * @param {} target + */ + function renderMap (target) { // Get text in code block starts with markdown text '```map' const configText = target .textContent // BE CAREFUL!!! 0xa0 char is "non-breaking spaces" in HTML text content @@ -463,7 +497,6 @@ export const generateMaps = (container, { const timer = setTimeout( () => { render(target, configList).forEach(renderPromise => { - renderPromises.push(renderPromise) renderPromise.then(afterMapRendered) }) Array.from(target.children).forEach(e => { @@ -476,14 +509,14 @@ export const generateMaps = (container, { }, delay ?? 1000, ) - onRemove(htmlHolder, () => { + onRemove(target.closest('.SemanticHtml'), () => { clearTimeout(timer) }) - }) + } - /** Prepare Context Menu */ + /** MENU: Prepare Context Menu */ const menu = document.createElement('div') - menu.className = 'menu' + menu.classList.add('menu', 'dumby-menu') menu.style.display = 'none' menu.onclick = (e) => { const keepMenu = e.target.closest('.keep-menu') || e.target.classList.contains('.keep-menu') @@ -491,9 +524,10 @@ export const generateMaps = (container, { menu.style.display = 'none' } - container.appendChild(menu) + document.body.appendChild(menu) + onRemove(menu, () => console.log('menu.removed')) - /** Menu Items for Context Menu */ + /** MENU: Menu Items for Context Menu */ container.oncontextmenu = e => { const map = e.target.closest('.mapclay') const block = e.target.closest('.dumby-block') @@ -502,6 +536,7 @@ export const generateMaps = (container, { menu.replaceChildren() menu.style.display = 'block' + console.log(menu.style.display) menu.style.cssText = `left: ${e.clientX - menu.offsetParent.offsetLeft + 10}px; top: ${e.clientY - menu.offsetParent.offsetTop + 5}px;` // Menu Items for map @@ -533,7 +568,7 @@ export const generateMaps = (container, { return menu } - /** Event Handler when clicking outside of Context Manu */ + /** MENU: Event Handler when clicking outside of Context Manu */ const actionOutsideMenu = e => { if (menu.style.display === 'none') return const keepMenu = e.target.closest('.keep-menu') || e.target.classList.contains('.keep-menu') @@ -542,9 +577,9 @@ export const generateMaps = (container, { const rect = menu.getBoundingClientRect() if ( e.clientX < rect.left || - e.clientX > rect.left + rect.width || - e.clientY < rect.top || - e.clientY > rect.top + rect.height + e.clientX > rect.left + rect.width || + e.clientY < rect.top || + e.clientY > rect.top + rect.height ) { menu.style.display = 'none' } @@ -554,7 +589,7 @@ export const generateMaps = (container, { document.removeEventListener('click', actionOutsideMenu), ) - /** Drag/Drop on map for new GeoLink */ + /** MOUSE: Drag/Drop on map for new GeoLink */ const pointByArrow = document.createElement('div') pointByArrow.className = 'point-by-arrow' container.appendChild(pointByArrow) diff --git a/src/editor.mjs b/src/editor.mjs index d56ad4b..3a70ae6 100644 --- a/src/editor.mjs +++ b/src/editor.mjs @@ -42,7 +42,12 @@ new window.MutationObserver(mutations => { childList: true, subtree: true, }) -let dumbymap +const dumbymap = generateMaps(dumbyContainer, { crs }) +if (initialLayout) { + dumbyContainer.dataset.layout = initialLayout +} +// Set oncontextmenu callback +dumbymap.utils.setContextMenu(menuForEditor) /** Variables: Reference Style Links in Markdown */ const refLinkPattern = /\[([^\x5B\x5D]+)\]:\s+(\S+)(\s["'](\S+)["'])?/ @@ -445,7 +450,7 @@ const completeForCodeBlock = change => { * @param {Event} event - Event for context menu * @param {HTMLElement} menu - menu of dumbymap */ -const menuForEditor = (event, menu) => { +function menuForEditor (event, menu) { event.preventDefault() if (document.getSelection().type === 'Range' && cm.getSelection() && refLinks.length > 0) { @@ -490,22 +495,16 @@ const menuForEditor = (event, menu) => { const updateDumbyMap = (callback = null) => { markdown2HTML(dumbyContainer, editor.value()) // debounceForMap(dumbyContainer, afterMapRendered) - dumbymap = generateMaps(dumbyContainer, { - crs, - }) + // dumbymap = generateMaps(dumbyContainer, { + // crs, + // }) // Set onscroll callback - const htmlHolder = dumbymap.htmlHolder - htmlHolder.onscroll = updateScrollLine(htmlHolder) - // Set oncontextmenu callback - dumbymap.utils.setContextMenu(menuForEditor) + // const htmlHolder = dumbymap.htmlHolder + // htmlHolder.onscroll = updateScrollLine(htmlHolder) callback?.(dumbymap) } -updateDumbyMap(() => { - if (initialLayout) { - dumbyContainer.dataset.layout = initialLayout - } -}) +updateDumbyMap() // Re-render HTML by editor content cm.on('change', (_, change) => { diff --git a/src/utils.mjs b/src/utils.mjs index 327bee4..9149ac2 100644 --- a/src/utils.mjs +++ b/src/utils.mjs @@ -5,7 +5,7 @@ * @param {Function} callback */ export const onRemove = (element, callback) => { - const parent = element.parentNode + const parent = element.parentElement if (!parent) throw new Error('The node must already be attached') const obs = new window.MutationObserver(mutations => { @@ -138,35 +138,40 @@ export const insideParent = (childElement, parentElement) => { * replaceTextNodes. * @description Search current nodes by pattern, and replace them by new node * @todo refactor to smaller methods - * @param {HTMLElement} element + * @param {HTMLElement} rootNode * @param {RegExp} pattern * @param {Function} newNode - Create new node by each result of String.prototype.matchAll */ export const replaceTextNodes = ( - element, + rootNode, pattern, - newNode = (match) => { + addNewNode = (match) => { const link = document.createElement('a') link.textContent(match.at(0)) return link }, ) => { const nodeIterator = document.createNodeIterator( - element, + rootNode, window.NodeFilter.SHOW_TEXT, - node => node.textContent.match(pattern) + node => node.textContent.match(pattern) && !node.parentElement.closest('code') ? window.NodeFilter.FILTER_ACCEPT : window.NodeFilter.FILTER_REJECT, ) let node = nodeIterator.nextNode() + const nodeArray = [] while (node) { let index = 0 for (const match of node.textContent.matchAll(pattern)) { const text = node.textContent.slice(index, match.index) + const newNode = addNewNode(match) + if (!newNode) continue + index = match.index + match.at(0).length node.parentElement.insertBefore(document.createTextNode(text), node) - node.parentElement.insertBefore(newNode(match), node) + node.parentElement.insertBefore(newNode, node) + nodeArray.push(newNode) } if (index < node.textContent.length) { const text = node.textContent.slice(index) @@ -176,6 +181,8 @@ export const replaceTextNodes = ( node.parentElement.removeChild(node) node = nodeIterator.nextNode() } + + return nodeArray } /** -- cgit v1.2.3-70-g09d2