Skip to content

Instantly share code, notes, and snippets.

@griponminds
Last active June 18, 2024 09:30
Show Gist options
  • Save griponminds/506f75ae0aa7d12e72a85571727a635f to your computer and use it in GitHub Desktop.
Save griponminds/506f75ae0aa7d12e72a85571727a635f to your computer and use it in GitHub Desktop.
Bookmarklet for CSS Container Queries
javascript:(() => {
/*! cq-resizer.js 2.0.2 | © 2023 grip on minds | MIT License (https://opensource.org/license/mit/) */
class CqResizer extends HTMLElement {
/* preferences */
#title = 'cq-resizer';
/* CSS */
#styles = `
@layer reset, layouts, components, theme;
@layer theme {
:host {
--color-fg : hsl(0 0% 6%);
--color-bg : hsl(170 5% 84%);
--color-border : hsl(0 0% 40%);
--color-accent : hsl(170 100% 23%);
--color-scheme : light;
--font-family : sans-serif;
--font-size : calc(1rem * 12 / var(--rem, 16));
--line-height : 1.4;
--inline-size : min(40em, calc(100% - 40px));
}
}
@layer components {
h1 {
padding-block: 10px;
padding-inline: 20px;
border-inline-end: solid 1px var(--color-border);
font-size: inherit;
font-weight: normal;
cursor: move;
}
.message {
padding: 20px;
}
ol {
display: grid;
gap: 20px;
padding: 20px;
counter-set: list-counter;
}
li {
display: grid;
grid-template-columns: auto 1fr auto auto;
gap: 20px;
align-items: center;
}
a {
color: var(--color-fg);
overflow-wrap: anywhere;
text-decoration: none;
}
.size {
display: grid;
grid-template-columns: repeat(2, auto);
column-gap: 5px;
align-content: start;
}
input[type="number"] {
inline-size: 5em;
padding-inline-start: 0.5em;
text-align: end;
}
input[type="number"][readonly] {
color: hsl(0 0% 40%);
}
button {
display: grid;
grid-template-columns: 1fr 16px;
column-gap: 4px;
align-items: center;
inline-size: 100%;
block-size: 100%;
padding-block: 10px;
padding-inline: 20px;
border: none;
border-radius: 0;
background: none;
appearance: none;
cursor: pointer;
}
@media (any-hover: hover) {
a:hover,
button:hover {
opacity: 0.7;
}
}
}
@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);
line-height: var(--line-height);
translate: var(--x, 0) var(--y, 0);
}
:host[data-is-move] {
will-change: translate;
}
.widget {
display: grid;
overflow: auto;
overscroll-behavior: contain;
inline-size: calc(100% - 40px);
block-size: auto;
max-block-size: calc(100svh - 40px - var(--y, 0px));
margin: 20px;
border: solid 1px var(--color-border);
background-color: var(--color-bg);
color: var(--color-fg);
}
.header {
position: sticky;
inset-block-start: 0;
z-index: 1;
display: grid;
grid-template-columns: 1fr auto;
justify-content: space-between;
align-items: center;
border-block-end: solid 1px var(--color-border);
background: var(--color-bg);
}
}
: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;
}
ol {
list-style-type: none;
}
input, button {
font: inherit;
}
button {
border: none;
background: none;
letter-spacing: inherit;
cursor: pointer;
}
:where(
button,
button:active
) {
color: inherit;
}
}
`;
/* アンカーリンクの CSS */
#anchorStyles = `
[data-cq-resizer-anchor] {
position: absolute;
inset: 0;
z-index: calc(infinity);
display: none;
border: solid 10px green;
opacity: 0;
pointer-events: none;
}
[data-cq-resizer-anchor="target"] {
display: block;
animation-name: highlight;
animation-duration: 1s;
animation-timing-function: ease;
animation-iteration-count: 2;
}
@keyframes highlight {
0% { opacity: 0; }
50% { opacity: 0.6; }
100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
[data-cq-resizer-anchor="target"] {
animation-timing-function: step-end;
animation-iteration-count: 1;
}
}
`;
/* elements */
#containers = null;
#html = null;
#shadow = null;
#anchorStyleElem = document.createElement('style');
/* others */
#idPrefix = 'cq-resizer';
#pos = new Map([
['x', 0],
['y', 0],
['fromX', 0],
['fromY', 0],
['moveX', 0],
['moveY', 0],
]);
#mainController = new AbortController();
#moveController = undefined;
#resizeObserver = undefined;
constructor() {
super();
}
connectedCallback() {
const styles = this.createStyles();
this.#containers = this.getContainers();
this.#html = this.createHTML();
if (!this.#html) return;
this.calcFontSizeRem();
this.#anchorStyleElem.textContent = this.#anchorStyles;
document.body.append(this.#anchorStyleElem);
this.#shadow = this.attachShadow({ mode: 'open' });
this.#shadow.append(styles);
this.#shadow.append(this.#html);
this.createAnchors();
const moveTarget = this.#html.querySelector('[data-move-target]');
const closeBtn = this.#html.querySelector('[data-close]');
const links = this.#html.querySelectorAll('a');
const inputNums = this.#html.querySelectorAll('input[type="number"]');
const checkboxes = this.#html.querySelectorAll('input[type="checkbox"]');
const { signal } = this.#mainController;
if (moveTarget) {
moveTarget.addEventListener('mousedown', this.handleMoveStart, { signal });
moveTarget.addEventListener('touchstart', this.handleMoveStart, { signal });
}
closeBtn?.addEventListener('click', () => {
this.setAttribute('data-remove', 'true');
}, { signal });
links.forEach((link) => {
link.addEventListener('click', () => {
const anchor = document.querySelector(link.getAttribute('href'));
anchor && anchor.setAttribute('data-cq-resizer-anchor', 'target');
}, { signal });
});
inputNums.forEach((input) => {
this.setInputNumberEvent(input);
});
checkboxes.forEach((checkbox) => {
checkbox.addEventListener('change', () => {
this.changeCheckbox(checkbox, checkbox.checked);
}, { signal });
});
}
disconnectedCallback() {
this.#mainController?.abort();
this.#moveController?.abort();
this.#resizeObserver?.disconnect();
}
/* ウィジェットの削除 */
static get observedAttributes() { return ['data-remove']; }
attributeChangedCallback(name, _, newValue) {
if (name === 'data-remove') {
if (newValue === 'true') {
this.resetContainers();
this.removeAnchors();
const parent = this.parentElement;
parent && parent.removeChild(this);
}
}
}
/* CSS 生成 */
createStyles = () => {
const styleElem = document.createElement('style');
styleElem.textContent = this.#styles;
return styleElem;
};
/* `input` からサイズの変更 */
setInputNumberEvent = (input) => {
const id = input?.getAttribute('data-input-id') ?? '';
const container = document.querySelector(`[data-container-id=${id}]`);
if (!container) return;
const { signal } = this.#mainController;
input.addEventListener('change', () => {
if (input.value >= 0) {
container.style.setProperty('width', `${input.value}px`);
}
}, { signal });
this.#resizeObserver = new ResizeObserver((entries) => {
for (let entry of entries) {
input.value = Math.round(entry.borderBoxSize[0].inlineSize);
}
});
this.#resizeObserver.observe(container);
};
/* チェックボックスの変更 */
changeCheckbox = (checkbox, checked) => {
const id = checkbox?.getAttribute('data-input-id') ?? '';
const inputNum = this.#html.querySelector(`input[type="number"][data-input-id=${id}]`);
const container = document.querySelector(`[data-container-id=${id}]`);
if (!inputNum || !container) return;
if (checked) {
inputNum.removeAttribute('readonly');
container.setAttribute('style', container.getAttribute('data-cq-resizer-style'));
} else {
inputNum.setAttribute('readonly', 'readonly');
container.setAttribute('data-cq-resizer-style', container.getAttribute('style'));
container.removeAttribute('style');
}
};
/* 親子関係にあり XY 座標が同じ子孫要素のコンテナを除外 */
removeSamePosContainers = (containers) => {
return containers.map((el) => {
const elRect = el.getBoundingClientRect();
const isSamePosition = containers.some((parent) => {
if (parent === el) return false;
const parentRect = parent.getBoundingClientRect();
if (
parent.contains(el) &&
(Math.round(parentRect.right) === Math.round(elRect.right)) &&
(Math.round(parentRect.bottom) === Math.round(elRect.bottom))
) {
return true;
}
return false;
});
if (isSamePosition) return false;
return el;
}).filter(Boolean);
};
/* `container-type: size` が一致する要素を取得 */
getContainers = () => {
const all = document.querySelectorAll('*');
const containers = Array.from(all).map((el) => {
const style = window.getComputedStyle(el);
return style.containerType.match(/size/) ? el : false;
}).filter(Boolean);
return this.removeSamePosContainers(containers);
};
/* コンテナのスタイルとカスタムデータ属性をリセット */
resetContainers = () => {
this.#containers.forEach((container) => {
const style = window.getComputedStyle(container);
if (style.containerType.includes('inline-size')) {
container.style.removeProperty('overflow-x');
} else if (style.containerType.includes('size')) {
container.style.removeProperty('overflow');
}
container.style.removeProperty('width');
container.style.removeProperty('height');
container.style.removeProperty('resize');
container.removeAttribute('data-container-id');
});
};
/* アンカーとスタイルの削除 */
removeAnchors = () => {
const anchors = document.querySelectorAll('[data-cq-resizer-anchor]');
anchors.forEach((anchor) => {
const parent = anchor.parentElement;
parent && parent.removeChild(anchor);
});
const styleParent = this.#anchorStyleElem.parentElement;
styleParent && styleParent.removeChild(this.#anchorStyleElem);
};
/* アンカー生成 */
createAnchors = () => {
const { signal } = this.#mainController;
this.#containers.forEach((container, index) => {
const anchor = document.createElement('span');
anchor.setAttribute('id', `${this.#idPrefix}-${index + 1}`);
anchor.setAttribute('aria-hidden', 'true');
anchor.setAttribute('data-cq-resizer-anchor', 'true');
anchor.addEventListener('animationend', () => {
anchor.setAttribute('data-cq-resizer-anchor', 'true');
}, { signal });
container.append(anchor);
});
};
/* HTML の特殊文字をエンティティ化 */
sanitizeHTML = (str) => {
if (typeof str !== 'string') return str;
return str
.replace(/[<>&'`"]/ug, (match) => {
return {
'<' : '&lt;',
'>' : '&gt;',
'&' : '&amp;',
'\'': '&#x27;',
'`' : '&#x60;',
'"' : '&quot;'
}[match];
});
};
/* リンクテキスト生成 */
createLinkText = (container) => {
const array = Array.from(container.classList);
if (array.length < 1) {
return `<${container?.tagName.toLowerCase()}>`;
}
return `.${array.join('.')}`;
};
/* リストアイテムの HTML を生成 */
createListItemHTML = () => {
if (!this.#containers) return;
let str = '';
this.#containers.forEach((container, index) => {
const style = window.getComputedStyle(container);
/* コンテナにリサイズハンドルを追加 */
if (style.containerType.includes('inline-size')) {
container.style.setProperty('resize', 'horizontal');
container.style.setProperty('overflow-x', 'auto');
} else if (style.containerType.includes('size')) {
container.style.setProperty('resize', 'both');
container.style.setProperty('overflow', 'auto');
}
const size = String(parseInt(getComputedStyle(container).getPropertyValue('inline-size'), 10));
const id = `${this.#idPrefix}-${index + 1}`;
container.setAttribute('data-container-id', id);
str += `
<li>
<span>${index + 1}</span>
<a href="#${id}">
${this.sanitizeHTML(this.createLinkText(container))}
</a>
<div class="size">
<input type="number" name="size" value=${size} data-input-id="${id}" />px
</div>
<input type="checkbox" name="show" data-input-id="${id}" checked />
</li>
`;
});
return str;
};
/* リストの HTML を生成 */
createListHTML = () => {
if (this.#containers.length < 1) {
return `
<p class="message">
<code>container-type</code> が指定されている要素は見つかりませんでした
</p>
`;
}
return `
<ol>
${this.createListItemHTML()}
</ol>
`;
};
/* HTML 生成 */
createHTML = () => {
const str = `
<div class="widget" role="region" aria-labelledby="title">
<div class="header" data-move-target>
<h1 id="title">${this.sanitizeHTML(this.#title)}</h1>
<button type="button" data-close>
Close
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
<line x1="4" y1="4" x2="12" y2="12" stroke="currentColor" stroke-width="1"/>
<line x1="12" y1="4" x2="4" y2="12" stroke="currentColor" stroke-width="1"/>
</svg>
</button>
</div>
${this.createListHTML()}
</div>
`;
const html = new DOMParser().parseFromString(str, 'text/html');
return html.body.firstElementChild;
};
/* ルート要素のフォントサイズを算出 */
calcFontSizeRem = () => {
const rem = getComputedStyle(document.documentElement).getPropertyValue('font-size');
this.style.setProperty('--rem', String(parseInt(rem, 10)));
};
/* イベントを取得 */
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('cq-resizer')) {
customElements.define('cq-resizer', CqResizer);
}
const widget = document.querySelector('cq-resizer');
if (!widget) {
const newWidget = document.createElement('cq-resizer');
document.body.prepend(newWidget);
newWidget.setAttribute('data-init', 'true');
}
if (widget) {
widget.setAttribute('data-remove', 'true');
}
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment