generated at
text-bubble-component
scrapbox-text-bubbleで使う、ページ先テキストを表示するUI部品

変えるところ
component内でfetchしない
テキストデータを外部から属性経由で提供させる
属性変化の挙動
lines : 表示するテキスト
新しいテキストを構文解析してDOMに反映させる
タイトル行は予め除いて渡す
path : /:project/:title
DOMの <a> のパスを変更する
構文解析はしない

アイデア
空リンクの識別ができたら良いなーと思ったtakker
api/code/:projectname/:pagetitleから、どれが空リンクかは調べられる
hoverして空リンクだったら、空リンクの色に変えるようにすると便利そう
空リンクかどうかは、何らかの手段で伝える
textBubble.changeToEmptyLink({project: 'xxx', title: 'yyy'}) とか?
2021-05-01 16:42:10 なぜかCSSの色が適用されない?
.empty-page-link ではなく :host の色がついてしまっている
class はちゃんと当てられている

script.js
const css = ` :host { padding: 5px 0px 5px 5px; font-size: 11px; line-height: 1.42857; user-select: text; position: absolute; background-color: var(--page-bg, #fefefe); color: var(--page-text-color, #4a4a4a); border-radius: 4px; box-shadow: 0 6px 12px rgba(0,0,0,0.175); z-index: 9000; } :host(:not([no-scroll])) { max-height: 80vh; overflow-y: auto; } `; import {parse} from '../scrapbox-preview-box%2Fparser/script.js'; import {fragment, h} from '../easyDOMgenerator/script.js'; const TAG_NAME = 'text-bubble'; customElements.define(TAG_NAME, class extends HTMLElement { constructor() { super(); const shadow = this.attachShadow({mode: 'open', delegatesFocus: true}); shadow.innerHTML = `<style>${css}</style><div id="box"></div>`; this._box = shadow.getElementById('box'); this._eventCallbacks = {}; } connectedCallback() { this.hide(); } get project() { return this._project; } get title() { return this._title; } set path(value) { this.setAttribute('path', value); } set contents(value) { this.setAttribute('contents', value); } set theme(value) { this.setAttribute('theme', value); } position({top, left}) { this.style.top = `${top}px`; this.style.left = `${left}px`; } show() { // 空っぽの場合は表示しない // defaultで<style>が含まれるので、nodeが2個以上のときのみ表示する if (this._box.childElementCount < 2) { this.hide(); return; } this.hidden = false; if(!this._lineId) return; /* // 行IDまでscrollする // web page全体も一緒にscrollされてしまうので、その分を戻しておく const scrollY = window.scrollY; this.shadowRoot.getElementById(`L${this._lineId}`)?.scrollIntoView?.({block: 'start'}); window.scroll(0, scrollY); */ } hide() { this.hidden = true; } changeToEmptyLink(project, title) { this._box.querySelectorAll(`.page-link[href^="/${project}/${title}"]`) .forEach(a => a.classList.add('empty-page-link')); } on(event, selecter, callback) { if (this._eventCallbacks[event]) this.shadowRoot.removeEventListener(event, this._eventCallbacks[event], {capture: true}); this._eventCallbacks[event] = e => { if (!e.target.matches(selecter)) return; callback(e); }; this.shadowRoot.addEventListener(event, this._eventCallbacks[event], {capture: true}); } static get observedAttributes() { return ['path', 'lines', 'theme', 'no-scroll']; } attributeChangedCallback(name, oldValue, newValue) { if (oldValue === newValue) return; switch(name) { case 'path': const [project, title] = decodeURIComponent(newValue) .match(/^\/([\w\-]+)\/(.+)$/).slice(1); this._changeLinkPath(this._project, this._title, project, title); this._project = project; this._title = title; break; case 'lines': this._createBody(JSON.parse(newValue)); break; case 'theme': // 未実装 break; } } _createBody(lines) { const lineDOMs = parse(lines.slice(1), this._project, this._title); if (!this._lineId) { this._replace(...lineDOMs); return; } // 行IDがある場合は、行ID以降のみを表示する const id = `L${this._lineId}`; const index = lineDOMs.findIndex(dom => dom.id === id); if (index < 0) { this._replace(...lineDOMs); return; } this._replace(lineDOMs[0], ...lineDOMs.slice(index)); // 先頭の<style>は削っちゃだめ // 行ID部分をhighlightしておく this.shadowRoot.getElementById(id).classList.add('permalink'); } _changeLinkPath(oldProject, oldTitle, newProject, newTitle) { this._box.querySelectorAll(`.page-link[href^="/${oldProject}"]`) .forEach(a => { const pathname = (new URL(a.href)).pathname; a.href = `/${newProject}${pathname.slice(`/${oldProject}`.length)}`; }); this._box.querySelectorAll(`.code-block-start a[href^="/api/code/${oldProject}"]`).forEach(a => { const pathname = (new URL(a.href)).pathname; a.href = `/api/code/${newProject}/${newTitle}${pathname.slice(`/api/code/${oldProject}/${oldTitle}`.length)}`; }); this._box.querySelectorAll(`.table-block-start a[href^="/api/table/${oldProject}"]`).forEach(a => { const pathname = (new URL(a.href)).pathname; a.href = `/api/table/${newProject}/${newTitle}${pathname.slice(`/api/table/${oldProject}/${oldTitle}`.length)}`; }); } _replace(...newElements) { this._box.textContent = ''; if (newElements.length === 0) return; this._box.append(...newElements); } }); export const textBubble = (...params) => h(TAG_NAME, ...params);

test code
js
import('/api/code/programming-notes/text-bubble-component/test1.js');
test1.js
import {textBubble} from './script.js'; import {scrapboxDOM} from '../scrapbox-dom-accessor/script.js'; (async () => { const path = '/programming-notes/scrapbox-preview-boxで表示するテキストのDOM構造'; const res = await fetch(`/api/pages${path}`); const {lines} = await res.json(); const box = textBubble({path, lines: JSON.stringify(lines)}); scrapboxDOM.editor.append(box); const observer = new MutationObserver(async () =>{ box.position({ top: parseInt(scrapboxDOM.cursor.style.top) + parseInt(scrapboxDOM.cursor.style.height), left: parseInt(scrapboxDOM.cursor.style.left), }); box.show(); }); observer.observe(scrapboxDOM.cursor, {attributes: true}); })();