Skip to content

Instantly share code, notes, and snippets.

@griponminds
Last active June 19, 2024 10:38
Show Gist options
  • Save griponminds/159fb9e3aac44b4d48d57cb87878ec29 to your computer and use it in GitHub Desktop.
Save griponminds/159fb9e3aac44b4d48d57cb87878ec29 to your computer and use it in GitHub Desktop.
Bookmarklet for CSS values
javascript:(() => {
/*! css-console.js 0.4.2 | © 2024 grip on minds | MIT License (https://opensource.org/license/mit/) */
class CSSConsole extends HTMLElement {
/* preferences */
#title = 'css-console';
#inputDebounce = 600;
/* CSS */
#styles = `
@layer reset, layouts, components, theme;
@layer theme {
:host {
--color-bg-base : hsl(180 0% 0%);
--color-bg-widget : hsl(180 10% 20%);
--color-bg-item : hsl(180 78% 10%);
--color-primary : hsl(180 17% 84%);
--color-secondary : hsl(180 17% 60%);
--color-border : hsl(180 17% 60%);
--color-highlight : hsl(180 12% 26%);
--value-bg : hsl(73 10% 70%);
--value-fg : hsl(180 0% 0%);
--value-border : hsl(180 78% 28%);
--btn-primary-bg : hsl(36 78% 54%);
--btn-primary-fg : hsl(180 78% 10%);
--btn-secondary-bg : hsl(36 35% 25%);
--btn-secondary-fg : hsl(180 17% 84%);
--color-scheme: dark;
--font-family: Menlo, Consolas, monospace;
--font-size-m: calc(1rem * 14 / var(--rem, 16));
--font-size-s: calc(1rem * 12 / var(--rem, 16));
--line-height: 1.6;
--letter-spacing: 0.05em;
--inline-size: min(460px, 100%);
--padding: 16px;
}
}
@layer components.item {
.item-container {
display: grid;
row-gap: 4px;
align-content: start;
overflow-y: auto;
overscroll-behavior: contain;
border: solid 4px var(--color-bg-base);
background-color: var(--color-bg-base);
}
.item {
display: grid;
gap: var(--padding);
align-items: start;
padding: var(--padding);
background-image: linear-gradient(to bottom, var(--color-highlight), var(--color-highlight));
background-size: 100% 1px;
background-repeat: no-repeat;
background-color: var(--color-bg-item);
overflow-wrap: anywhere;
}
.item-num {
color: var(--color-secondary);
font-size: var(--font-size-s);
line-height: 1.1;
}
.field {
display: grid;
row-gap: var(--padding);
}
.field-item {
display: grid;
row-gap: 4px;
}
.field-label {
font-size: var(--font-size-s);
text-transform: lowercase;
}
.field-target {
inline-size: 100%;
padding-block: 8px;
padding-inline: 16px;
border: solid 1px var(--color-border);
border-radius: 4px;
background: none;
}
.field-target:focus {
border-color: var(--color-primary);
}
.field-target[data-inspector-id] {
border-color: var(--btn-primary-bg);
}
.field-target[name="selector"] {
padding-inline-end: calc(16px + 40px);
}
.field-selector {
position: relative;
}
.field-selector-btn {
--btn-fg: var(--btn-primary-fg);
--btn-bg: var(--btn-primary-bg);
--btn-fg-hover: color-mix(in srgb, var(--btn-fg) 80%, var(--color-bg-item));
--btn-bg-hover: color-mix(in srgb, var(--btn-bg) 80%, var(--color-bg-item));
position: absolute;
inset-block: 0;
inset-inline-end: 0;
display: grid;
place-items: center;
border-start-end-radius: 4px;
border-end-end-radius: 4px;
background-color: var(--btn-bg);
color: var(--btn-fg);
}
.field-selector-btn svg {
fill: currentColor;
}
.field-value {
display: block;
min-block-size: calc((1em * var(--line-height)) + (12px * 2) + (4px * 2));
padding: 12px;
border: solid 4px var(--value-border);
border-radius: 8px;
box-shadow: inset 0 2px 0 0 color-mix(in srgb, var(--value-bg) 30%, black);
background-color: var(--value-bg);
color: var(--value-fg);
}
.field-value-target[data-calc] {
animation: calc 0.2s step-end;
}
@keyframes calc {
0% { opacity: 0; }
100% { opacity: 1; }
}
.controls {
display: flex;
column-gap: 16px;
justify-self: end;
}
.btn {
display: grid;
gap: 4px;
place-items: center;
font-size: var(--font-size-s);
line-height: 1.1;
text-align: center;
text-transform: lowercase;
}
.btn-icon {
--btn-fg: var(--btn-secondary-fg);
--btn-bg: var(--btn-secondary-bg);
inline-size: 1.8em;
aspect-ratio: 1;
border: solid 1px var(--color-bg-base);
border-radius: 50%;
background-color: var(--btn-bg);
color: var(--btn-fg);
}
.btn.--calc .btn-icon {
--btn-fg: var(--btn-primary-fg);
--btn-bg: var(--btn-primary-bg);
}
.btn-icon svg {
inline-size: 100%;
fill: currentColor;
}
.item:only-child .btn.--remove {
display: none;
}
@container (min-inline-size: 320px) {
.btn {
grid-template-columns: auto 1fr;
}
}
@media (any-hover: hover) {
.field-selector-btn:hover {
background-color: var(--btn-bg-hover);
color: var(--btn-fg-hover);
}
.btn:hover {
opacity: 0.8;
}
}
}
@layer components.header {
.header {
position: sticky;
inset-block-start: 0;
z-index: 10;
display: grid;
grid-template-columns: 1fr auto;
column-gap: 8px;
align-items: center;
padding-block-end: 8px;
padding-inline: 8px;
background-color: var(--color-bg-widget);
color: var(--color-primary);
font-size: var(--font-size-s);
}
.header svg {
fill: currentColor;
}
.header-movable {
display: grid;
grid-template-columns: 12px 1fr;
column-gap: 8px;
align-items: center;
padding-block: 8px;
}
.header-movable[data-move-target] {
cursor: move;
}
.header-movable:not([data-move-target]) svg {
display: none;
}
.header-hdg {
overflow: hidden;
text-overflow: ellipsis;
font-size: inherit;
font-weight: normal;
line-height: 1.2;
white-space: nowrap;
word-spacing: -0.5ch;
}
.header-close {
position: relative;
z-index: 10;
display: grid;
grid-template-columns: auto auto;
column-gap: 8px;
align-items: center;
justify-self: end;
text-transform: uppercase;
}
.header-close-icon {
display: block;
inline-size: 12px;
block-size: 12px;
padding: 2px;
border: solid 1px currentColor;
}
@media (any-hover: hover) {
.header-close:hover {
opacity: 0.8;
}
}
}
@property --x {
syntax: '<length>';
inherits: false;
initial-value: 0;
}
@property --y {
syntax: '<length>';
inherits: false;
initial-value: 0;
}
@layer layouts {
:host {
position: fixed;
inset-block-start: 0;
inset-inline-end: 0;
z-index: calc(infinity);
display: var(--display, block);
inline-size: var(--inline-size);
color-scheme: var(--color-scheme);
font-family: var(--font-family);
font-size: var(--font-size-m);
line-height: var(--line-height);
letter-spacing: var(--letter-spacing);
translate: var(--x, 0) var(--y, 0);
}
:host[data-is-move] {
will-change: translate;
}
.container {
container-type: inline-size;
overflow: auto;
overscroll-behavior: contain;
max-block-size: calc(100svh - 40px - var(--y, 0px));
margin: 20px;
border: solid 8px var(--color-bg-widget);
border-radius: 8px;
background-color: var(--color-bg-item);
color: var(--color-primary);
resize: both;
}
}
:host {
all: revert-layer !important;
}
@layer reset {
:host {
writing-mode: horizontal-tb;
font-style: initial;
font-weight: initial;
font-variant: initial;
line-height: initial;
letter-spacing: initial;
text-align: initial;
text-indent: initial;
text-transform: initial;
word-spacing: initial;
text-shadow: initial;
cursor: initial;
}
* {
margin: 0;
padding: 0;
}
*,
::after,
::before {
box-sizing: border-box;
}
ul {
list-style-type: none;
}
svg {
display: block;
block-size: auto;
max-inline-size: 100%;
}
input, button {
font: inherit;
}
button {
border: none;
background: none;
letter-spacing: inherit;
cursor: pointer;
}
:where(
button,
button:active
) {
color: inherit;
}
}
`;
/* elements */
#container = null;
#itemContainer = null;
#shadow = null;
#inspector = null;
#inspectorInfo = null;
/* others */
#itemId = 0;
#timer = undefined;
#inspectorPrev = null;
#inspectorMode = false;
#pos = new Map([
['x', 0],
['y', 0],
['fromX', 0],
['fromY', 0],
['moveX', 0],
['moveY', 0],
]);
#mainController = new AbortController();
#moveController = undefined;
#inspectController = undefined;
constructor() {
super();
}
connectedCallback() {
const styles = this.createStyles();
this.#container = this.createContainerHTML();
if (!this.#container) return;
this.calcFontSizeRem();
this.#shadow = this.attachShadow({ mode: 'open' });
this.#shadow.append(styles);
this.#shadow.append(this.#container);
this.#itemContainer = this.#container.querySelector('[data-item-container]');
const moveTarget = this.#container.querySelector('[data-move-target]');
const closeBtn = this.#container.querySelector('[data-close]');
this.appendListItem();
const { signal } = this.#mainController;
if (moveTarget) {
moveTarget.addEventListener('mousedown', this.handleMoveStart, { signal });
moveTarget.addEventListener('touchstart', this.handleMoveStart, { signal });
}
closeBtn?.addEventListener('click', () => {
this.setAttribute('data-toggle', 'true');
}, { signal });
this.createInspector();
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.#inspectorMode) {
this.setAttribute('data-toggle', 'true');
}
}, { signal });
}
disconnectedCallback() {
this.#mainController?.abort();
this.#moveController?.abort();
}
/* ウィジェットの位置とサイズをリセット */
resetPosAndSizes = () => {
this.style.removeProperty('--x');
this.style.removeProperty('--y');
if (this.#container) {
this.#container.style.removeProperty('width');
this.#container.style.removeProperty('height');
this.#container.scrollTop = 0;
}
this.#pos.forEach((_, key) => this.#pos.set(key, 0));
};
/* ウィジェットの表示・非表示の制御 */
static get observedAttributes() { return ['data-toggle']; }
attributeChangedCallback(name, _, newValue) {
if (name === 'data-toggle') {
if (newValue === 'true') {
this.removeAttribute('data-toggle');
if (!this.#inspectorMode) {
this.resetPosAndSizes();
}
const isDisplay = (this.style.getPropertyValue('--display') !== 'none');
if (isDisplay) {
this.style.setProperty('--display', 'none');
} else {
this.style.removeProperty('--display');
this.disableInspectorMode();
}
}
}
}
/* リストアイテム追加 */
appendListItem = (prev = false) => {
if (!this.#itemContainer) return;
const item = this.createItemHTML();
if (prev) {
this.#itemContainer.insertBefore(item, prev.nextSibling);
} else {
this.#itemContainer.append(item);
}
const selector = item.querySelector('[name="selector"]');
const prop = item.querySelector('[name="prop"]');
const output = item.querySelector('[name="val"]');
const btnAdd = item.querySelector('[name="add"]');
const btnRemove = item.querySelector('[name="remove"]');
const btnCalc = item.querySelector('[name="calc"]');
const btnInspector = item.querySelector('[name="inspector"]');
const { signal } = this.#mainController;
selector?.addEventListener('input', () => {
this.debounce(this.calcValue, [selector, prop, output], this.#inputDebounce);
selector.removeAttribute('data-inspector-id');
}, { signal });
prop?.addEventListener('input', () => {
this.debounce(this.calcValue, [selector, prop, output], this.#inputDebounce);
}, { signal });
btnAdd?.addEventListener('click', () => {
this.appendListItem(item);
}, { signal });
btnRemove?.addEventListener('click', () => {
this.#itemContainer.removeChild(item);
}, { signal });
btnCalc?.addEventListener('click', () => {
this.calcValue(selector, prop, output);
}, { signal });
output?.addEventListener('animationend', (e) => {
if (e.animationName === 'calc') {
output.removeAttribute('data-calc');
}
}, { signal });
btnInspector?.addEventListener('click', (e) => {
e.stopPropagation();
this.enableInspectorMode(selector, prop, output);
}, { signal });
};
/* スタイルの値を計算 */
calcValue = (selector, prop, output) => {
if (!output) return;
output.setAttribute('data-calc', 'true');
const setOutputText = (text) => (output.textContent = text);
if (!selector.value || !prop.value) {
setOutputText('');
return;
}
/* インスペクタで指定したカスタムデータ属性を照合 */
let customData = '';
if (selector.getAttribute('data-inspector-id')) {
customData = `[data-inspector-id="${selector.getAttribute('data-inspector-id')}"]`;
}
const elems = selector.value.split(/::?(before|after)/);
const pseudoElt = elems[1];
try {
const elem = customData ? customData : elems[0];
const targetElem = document.querySelector(elem);
if (targetElem) {
const value = getComputedStyle(targetElem, pseudoElt).getPropertyValue(prop.value);
setOutputText(value ? value : '/* empty */');
} else {
setOutputText('/* selector not found */');
}
} catch(e) {
setOutputText('/* error */');
console.log(e.message);
}
};
/* CSS 生成 */
createStyles = () => {
const styleElem = document.createElement('style');
styleElem.textContent = this.#styles;
return styleElem;
};
/* アイテムの HTML を生成 */
createItemHTML = () => {
this.#itemId++;
const string = `
<li class="item">
<p class="item-num">[${this.#itemId}]</p>
<div class="field">
<div class="field-item">
<label for="output-${this.#itemId}" class="field-label">Value</label>
<div class="field-value">
<output name="val" id="output-${this.#itemId}" for="selector-${this.#itemId} prop-${this.#itemId}" class="field-value-target"></output>
</div>
</div>
<div class="field-item">
<label for="selector-${this.#itemId}" class="field-label">Selector</label>
<div class="field-selector">
<input type="text" id="selector-${this.#itemId}" class="field-target" name="selector" value="" placeholder=":root" spellcheck="false">
<button type="button" name="inspector" class="field-selector-btn"" tabindex="-1">
<svg width="40" height="40" viewBox="0 0 40 40" role="img">
<title>Inspector</title>
<path d="M24 12H16C13.7909 12 12 13.7909 12 16V24C12 26.2091 13.7909 28 16 28H18V26H16C14.8954 26 14 25.1046 14 24V16C14 14.8954 14.8954 14 16 14H24C25.1046 14 26 14.8954 26 16V18H28V16C28 13.7909 26.2091 12 24 12Z"/>
<path d="M20 20H22V28H20V20Z"/>
<path d="M21 22.4141L22.4142 20.9998L28.006 26.5916L26.5918 28.0059L21 22.4141Z"/>
<path d="M28 20V22L20 22L20 20H28Z"/>
</svg>
</button>
</div>
</div>
<div class="field-item">
<label for="prop-${this.#itemId}" class="field-label">Property</label>
<input type="text" id="prop-${this.#itemId}" class="field-target" name="prop" value="" placeholder="color" spellcheck="false">
</div>
</div>
<div class="controls">
<div>
<button type="button" name="remove" class="btn --remove">
<div class="btn-icon">
<svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true">
<line x1="6" y1="12" x2="18" y2="12" stroke="currentColor" stroke-width="2"/>
</svg>
</div>
Remove
</button>
</div>
<div>
<button type="button" name="add" class="btn --add">
<div class="btn-icon">
<svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true">
<line x1="6" y1="12" x2="18" y2="12" stroke="currentColor" stroke-width="2"/>
<line x1="12" y1="6" x2="12" y2="18" stroke="currentColor" stroke-width="2"/>
</svg>
</div>
Add
</button>
</div>
<div>
<button type="button" name="calc" class="btn --calc">
<div class="btn-icon">
<svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true">
<line x1="6" y1="10" x2="18" y2="10" stroke="currentColor" stroke-width="2"/>
<line x1="6" y1="14" x2="18" y2="14" stroke="currentColor" stroke-width="2"/>
</svg>
</div>
Calc
</button>
</div>
</div>
</li>
`;
const html = new DOMParser().parseFromString(string, 'text/html');
return html.body.firstElementChild;
};
/* HTML の特殊文字をエンティティ化 */
sanitizeHTML = (str) => {
if (typeof str !== 'string') return str;
return str
.replace(/[<>&'`"]/ug, (match) => {
return {
'<' : '&lt;',
'>' : '&gt;',
'&' : '&amp;',
'\'': '&#x27;',
'`' : '&#x60;',
'"' : '&quot;'
}[match];
});
};
/* HTML 生成 */
createContainerHTML = () => {
const string = `
<div class="container" role="region" aria-labelledby="title">
<header class="header">
<div class="header-movable" data-move-target>
<svg width="12" height="12" viewBox="0 0 10 10" aria-hidden="true">
<circle cx="3" cy="1" r="1"/><circle cx="7" cy="1" r="1"/>
<circle cx="3" cy="5" r="1"/><circle cx="7" cy="5" r="1"/>
<circle cx="3" cy="9" r="1"/><circle cx="7" cy="9" r="1"/>
</svg>
<h1 id="title" class="header-hdg">${this.sanitizeHTML(this.#title)}</h1>
</div>
<button type="button" class="header-close" data-close>
Close
<span class="header-close-icon">
<svg width="8" height="8" viewBox="0 0 8 8" aria-hidden="true">
<line x1="0" y1="0" x2="8" y2="8" stroke="currentColor" stroke-width="2"/>
<line x1="8" y1="0" x2="0" y2="8" stroke="currentColor" stroke-width="2"/>
</svg>
</span>
</button>
</header>
<ul class="item-container" data-item-container></ul>
</div>
`;
const html = new DOMParser().parseFromString(string, 'text/html');
return html.body.firstElementChild;
};
/* ルート要素のフォントサイズを算出 */
calcFontSizeRem = () => {
const rem = getComputedStyle(document.documentElement).getPropertyValue('font-size');
this.style.setProperty('--rem', String(parseInt(rem, 10)));
};
/* スクロール位置のリセット */
resetScollTop = () => {
if (this.#itemContainer) {
this.#itemContainer.scrollTop = 0;
}
};
/* debounce で処理を間引く */
debounce = (fn, args = null, interval = 600) => {
clearTimeout(this.#timer);
this.#timer = setTimeout (() => fn(...args), interval);
};
/* 配列データからスタイルを指定 */
setStylesFromObject = (target, obj) => {
for (const [key, value] of Object.entries(obj)) {
target.style.setProperty(key, value);
}
};
/* インスペクタ要素の生成 */
createInspector = () => {
this.#inspector = document.createElement('div');
const inspectorStyles = {
'position': 'fixed',
'z-index': 'calc(infinity)',
'display': 'none',
'background': 'hsl(180 78% 70% / 0.5)',
'pointer-events': 'none',
};
this.setStylesFromObject(this.#inspector, inspectorStyles);
this.#inspectorInfo = document.createElement('div');
const infoStyles = {
'inline-size': 'fit-content',
'padding-inline': '0.5em',
'color': 'white',
'background': 'hsl(180 78% 10%)',
'font-size': 'calc(1rem * 12 / 16)',
'font-family': 'Menlo, Consolas, monospace',
'line-height': '1.6',
'text-shadow': 'none',
};
this.setStylesFromObject(this.#inspectorInfo, infoStyles);
this.#inspector.append(this.#inspectorInfo);
document.body.append(this.#inspector);
};
/* セレクタのテキストを取得 */
getSelectorText = (target) => {
if (!target) return;
let suffix = '';
if (target.id) {
suffix += '#' + target.id;
}
/* クラスの前後の不要な空白、タブ、改行を除去し、ドット `.` で連結する */
const createClassString = (className) => {
if (typeof className !== 'string') return className;
return className
.trim()
.replace(/[\t\n]/ug, '')
.replace(/[  ]+/ug, ' ')
.replace(/\s/ug, '.');
};
if (target.className && typeof target.className === 'string') {
suffix += '.' + createClassString(target.className);
}
return target.nodeName.toLowerCase() + suffix;
};
/* インスペクタを移動 */
moveInspector = (e) => {
const target = document.elementFromPoint(e.clientX, e.clientY);
if (target === this.#inspectorPrev) return;
this.#inspectorPrev = target;
this.#inspectorInfo.textContent = this.getSelectorText(target);
const rect = target.getBoundingClientRect();
const styles = {
'width' : `${rect.width}px`,
'height' : `${rect.height}px`,
'top' : `${rect.top}px`,
'left' : `${rect.left}px`,
};
this.setStylesFromObject(this.#inspector, styles);
};
/* インスペクタモードの解除 */
disableInspectorMode = () => {
if (this.#inspectorMode) {
this.#inspectorMode = false;
this.#inspector.style.setProperty('display', 'none');
this.#inspectController && this.#inspectController.abort();
}
};
/* インスペクタモードを有効化 */
enableInspectorMode = (selector, prop, output) => {
this.#inspectorMode = true;
this.#inspectController = new AbortController();
const { signal } = this.#inspectController;
this.#inspector.style.setProperty('display', 'block');
this.setAttribute('data-toggle', 'true');
/* リンク遷移を防ぐため一時的に無効化 */
const links = document.querySelectorAll('a');
links.forEach((link) => {
link.addEventListener('click', (e) => {
e.preventDefault();
}, { signal });
});
document.addEventListener('mousemove', (e) => {
this.moveInspector(e);
}, { signal });
document.addEventListener('click', (e) => {
e.preventDefault();
const target = document.elementFromPoint(e.clientX, e.clientY);
if (selector && target) {
const now = new Date().valueOf();
const id = target.getAttribute('data-inspector-id') ?? '';
const setId = id.split('#').concat('' + now).join('#');
selector.value = this.getSelectorText(target);
selector.setAttribute('data-inspector-id', setId);
target.setAttribute('data-inspector-id', setId);
this.calcValue(selector, prop, output);
}
this.setAttribute('data-toggle', 'true');
}, { signal });
};
/* イベントを取得 */
getEvent = (e) => {
return ('touches' in e) ? e.touches[0] : e;
};
/* ウィジェットをドラッグして移動 */
handleMoveStart = (e) => {
e.preventDefault();
this.#moveController = new AbortController();
const { signal } = this.#moveController;
const event = this.getEvent(e);
this.setAttribute('data-is-move', 'true');
this.#pos.set('fromX', event.clientX);
this.#pos.set('fromY', event.clientY);
document.addEventListener('mousemove', this.handleMove, { passive: false, signal });
document.addEventListener('touchmove', this.handleMove, { passive: false, signal });
document.addEventListener('mouseup', this.handleMoveEnd, { signal });
document.addEventListener('touchend', this.handleMoveEnd, { signal });
};
handleMove = (e) => {
e.preventDefault();
const event = this.getEvent(e);
this.#pos.set('moveX', event.clientX - this.#pos.get('fromX'));
this.#pos.set('moveY', event.clientY - this.#pos.get('fromY'));
this.#pos.set('x', this.#pos.get('x') + this.#pos.get('moveX'));
this.#pos.set('y', this.#pos.get('y') + this.#pos.get('moveY'));
this.style.setProperty('--x', `${this.#pos.get('x')}px`);
this.style.setProperty('--y', `${this.#pos.get('y')}px`);
this.#pos.set('fromX', event.clientX);
this.#pos.set('fromY', event.clientY);
};
handleMoveEnd = () => {
this.removeAttribute('data-is-move');
this.#moveController.abort();
};
}
/* カスタム要素の初期化 */
{
if (!customElements.get('css-console')) {
customElements.define('css-console', CSSConsole);
}
const widget = document.querySelector('css-console');
if (!widget) {
const newWidget = document.createElement('css-console');
document.body.prepend(newWidget);
newWidget.setAttribute('data-init', 'true');
}
if (widget) {
widget.setAttribute('data-toggle', 'true');
}
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment