react-sass-inlinesvg
Version:
React library designed to control SVG from Sass.
360 lines (359 loc) • 15.7 kB
JavaScript
var __rest = (this && this.__rest) || function (s, e) {
var t = {};
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
t[p] = s[p];
if (s != null && typeof Object.getOwnPropertySymbols === "function")
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
t[p[i]] = s[p[i]];
}
return t;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.updateTextNode = exports.renderStoryCatalog = exports.setup = exports.ForTest = exports.animationNamePrefix = void 0;
const react_1 = __importDefault(require("react"));
const p_settle_1 = __importDefault(require("p-settle"));
const react_use_1 = require("react-use");
const react_inlinesvg_1 = require("./react-inlinesvg");
exports.animationNamePrefix = "svg_";
const State = {
cacheObject: {},
updateQueueObject: {},
aggregation: {
start: { queue: [] },
fetch: { queue: [] },
render: { queue: [] },
},
setupCompleted: false,
};
class ForTest {
}
exports.ForTest = ForTest;
ForTest.state = State;
function setup(pathMap, options = {}) {
return {
SVG: react_1.default.memo(createSvg(pathMap, options)),
pathMap,
};
}
exports.setup = setup;
function renderStoryCatalog(SVG, pathMap, className, useDefault = false) {
return (react_1.default.createElement("div", { className: className }, Object.keys(pathMap).map((svgName) => (react_1.default.createElement("section", { key: svgName, "data-svg-name": svgName },
react_1.default.createElement("h1", null, svgName),
react_1.default.createElement(SVG, { defaultName: useDefault ? svgName : undefined }))))));
}
exports.renderStoryCatalog = renderStoryCatalog;
function createSvg(pathMap, { fetchOptions, uniquifyIDs, uniqueHash } = {}) {
const options = {
fetchOptions,
uniquifyIDs,
uniqueHash: uniqueHash || (0, react_inlinesvg_1.randomString)(8),
};
const Component = (_a) => {
var { onLoad, onError, innerRef, defaultName, title, description } = _a, props = __rest(_a, ["onLoad", "onError", "innerRef", "defaultName", "title", "description"]);
const elementRef = react_1.default.useRef(null);
const propsRef = react_1.default.useRef(null);
const [defaultNameEnabled] = react_1.default.useState(() => Boolean(defaultName));
const svgName = useSvgName(pathMap, defaultName, elementRef, propsRef, options);
(0, react_use_1.useIsomorphicLayoutEffect)(() => {
const initializing = !propsRef.current;
propsRef.current = { title, description, onError, onLoad };
// 初回描画時は処理が不要であり、軽量化するためにスキップ
if (!initializing && elementRef.current) {
updateTextNode(elementRef.current, { title, description });
}
}, [onLoad, onError, title, description]);
(0, react_use_1.useIsomorphicLayoutEffect)(() => {
const element = elementRef.current;
if (!element || !defaultName || svgNameIsEmptyType(defaultName)) {
return;
}
updateElement(element, defaultName, pathMap, propsRef, options);
}, []);
(0, react_use_1.useEffectOnce)(() => {
// 一番最初にここへ到達した処理のみが実施する初期処理
if (!State.setupCompleted) {
setupStyle(Object.keys(pathMap));
State.setupCompleted = true;
}
if (defaultNameEnabled) {
return;
}
// 初回のsvg反映処理の開始
if (elementRef.current) {
aggregateProcess(State.aggregation.start, { element: elementRef.current }, (list) => {
list.map(({ element }) => {
element.dataset.svgStatus = "loading";
});
});
}
});
if (svgName === "NULL") {
return null;
}
// スケルトンを描画
return (react_1.default.createElement("svg", Object.assign({}, resolveBaseProps(svgName), props, { ref: (ref) => {
elementRef.current = ref;
if (innerRef instanceof Function) {
innerRef(ref);
}
else if (innerRef) {
//eslint-disable-next-line @typescript-eslint/ban-ts-comment
// 外部からrefが渡されている場合にreadonlyを無視して書き換える必要がある
Object.assign(innerRef, { current: ref });
}
} })));
};
Component.displayName = "Svg";
return Component;
}
function resolveBaseProps(svgName) {
if (svgNameIsEmptyType(svgName)) {
return {
"data-svg-name": svgName,
"data-svg-status": "complete",
};
}
return {
"aria-busy": true,
};
}
function useSvgName(pathMap, defaultName, elementRef, propsRef, options) {
const [svgName, setSvgName] = react_1.default.useState(defaultName);
(0, react_use_1.useEffectOnce)(() => {
const element = elementRef.current;
if (element) {
const handler = (event) => {
// svg_SvgName を含む形式のアニメーション名をsvg名として検出
const svgName = resolveSvgName(event);
// 特定のアニメーション名の形式に該当しない場合はその他のアニメーションなので無視する
if (!svgName) {
return;
}
event.stopPropagation();
if (svgName === "NULL") {
setSvgName(svgName);
}
else {
updateElement(element, svgName, pathMap, propsRef, options);
}
};
element.addEventListener("animationstart", handler, true);
return () => {
element.removeEventListener("animationstart", handler, true);
};
}
});
return svgName;
}
function updateElement(element, svgName, pathMap, propsRef, options) {
if (svgNameIsEmptyType(svgName)) {
updateElementForEmpty(element, svgName);
return;
}
if (svgName in State.cacheObject) {
updateElementByCache(element, svgName, propsRef, options, pathMap[svgName]());
return;
}
updateElementByFetch(element, svgName, pathMap, propsRef, options);
}
function updateElementByFetch(element, svgName, pathMap, propsRef, options) {
var _a;
const onError = ((_a = propsRef.current) === null || _a === void 0 ? void 0 : _a.onError) || null;
if (!(svgName in pathMap)) {
onError === null || onError === void 0 ? void 0 : onError(new TypeError(`unknown svgName "${svgName}"`));
updateElementForError(element, svgName);
return;
}
updateElementForLoading(element, svgName);
if (svgName in State.updateQueueObject) {
State.updateQueueObject[svgName].push(element);
return;
}
State.updateQueueObject[svgName] = [element];
// 取得処理は一定数まとめたほうが処理時間を短縮できる様子
aggregateProcess(State.aggregation.fetch, { svgName, element, propsRef }, (list) => {
(0, p_settle_1.default)(list.map(({ svgName }) => createFetch(pathMap[svgName](), options)))
.then((results) => {
results.map((result, index) => {
var _a;
if (result.isRejected) {
const propsRef = list[index].propsRef;
const onError = (_a = propsRef.current) === null || _a === void 0 ? void 0 : _a.onError;
// TODO: State.updateQueueObject に溜まっているものにも反映する
if (result.reason instanceof Error) {
onError === null || onError === void 0 ? void 0 : onError(result.reason);
}
else {
onError === null || onError === void 0 ? void 0 : onError(new TypeError("pSettle rejected"));
}
return;
}
const svgName = list[index].svgName;
const src = pathMap[svgName]();
State.cacheObject[svgName] = parse(result.value);
State.updateQueueObject[svgName].forEach((element, index) => {
const hasCache = index !== 0;
updateElementByCache(element, svgName, propsRef, options, src, hasCache);
});
State.updateQueueObject[svgName] = [];
});
})
.catch((error) => {
if (error instanceof Error) {
onError === null || onError === void 0 ? void 0 : onError(error);
}
});
});
}
function createFetch(url, options) {
return fetch(url, options.fetchOptions).then(react_inlinesvg_1.responseHandler);
}
function updateElementForEmpty(element, svgName) {
const render = () => {
resetAttributes(element);
element.dataset.svgName = svgName;
element.dataset.svgStatus = "complete";
element.removeAttribute("aria-busy");
element.innerHTML = "";
};
aggregateProcess(State.aggregation.render, { render }, (list) => {
requestAnimationFrame(() => {
list.forEach(({ render }) => render());
});
}, 16, 32);
}
function resetAttributes(element) {
// 前回のsvgの属性を削除
if (element.dataset.attributeNames) {
element.dataset.attributeNames.split(" ").forEach((name) => {
element.removeAttribute(name);
});
element.removeAttribute("data-attribute-names");
}
}
function updateElementForLoading(element, svgName) {
resetAttributes(element);
element.dataset.svgName = svgName;
element.dataset.svgStatus = "loading";
element.setAttribute("aria-busy", "true");
element.innerHTML = "";
}
function updateElementForError(element, svgName) {
element.dataset.svgName = svgName;
element.dataset.svgStatus = "error";
element.removeAttribute("aria-busy");
}
function updateElementByCache(element, svgName, propsRef, options, src, hasCache = true) {
const render = () => {
var _a, _b;
const { attributes, content } = State.cacheObject[svgName];
const attributeNames = [];
resetAttributes(element);
attributes.forEach(({ name, value }) => {
element.setAttribute(name, value);
attributeNames.push(name);
});
element.dataset.svgName = svgName;
element.dataset.svgStatus = "complete";
element.dataset.attributeNames = attributeNames.join(" "); // 今回のsvgの属性を登録
element.removeAttribute("aria-busy");
element.innerHTML = content;
propsRef.current && updateTextNode(element, propsRef.current);
(0, react_inlinesvg_1.updateSVGAttributes)(element, {
baseURL: "",
uniquifyIDs: options.uniquifyIDs,
hash: options.uniqueHash,
});
(_b = (_a = propsRef.current) === null || _a === void 0 ? void 0 : _a.onLoad) === null || _b === void 0 ? void 0 : _b.call(_a, src, hasCache);
};
aggregateProcess(State.aggregation.render, { render }, (list) => {
requestAnimationFrame(() => {
list.forEach(({ render }) => render());
});
}, 16, 32);
}
function updateTextNode(svg, { title, description }) {
var _a, _b;
const svgName = svg.dataset.svgName;
if (svgNameIsEmptyType(svgName)) {
return;
}
if (description) {
(_a = svg.querySelector("desc")) === null || _a === void 0 ? void 0 : _a.remove();
const descElement = document.createElement("desc");
descElement.textContent = description;
svg.prepend(descElement);
}
if (title) {
(_b = svg.querySelector("title")) === null || _b === void 0 ? void 0 : _b.remove();
const titleElement = document.createElement("title");
titleElement.textContent = title;
svg.prepend(titleElement);
}
}
exports.updateTextNode = updateTextNode;
function aggregateProcess(state, data, run, delayMs = 16, unitSize = 16) {
// 処理が集中したとき、全てをキューに追加し、1つ目のプロセスのみが非同期で一括でキューの処理を捌いていく。
// 指定されたdelayMsとunitSizeでキューの中身を処理するため、キューが空になる前に追加された分も順番に処理されていく。
// キューの処理を全て実行し終えたらキューを空にして再び元の状態に戻る。
state.queue.push(data);
if (state.queue.length === 1) {
// 次の処理の予約
function reserve(timerMs, pos = 0) {
setTimeout(() => {
const nextPos = pos + unitSize;
run(state.queue.slice(pos, nextPos));
if (state.queue.length <= nextPos) {
state.queue = [];
}
else {
reserve(delayMs, nextPos);
}
}, timerMs);
}
reserve(0);
}
}
function parse(text) {
const svgStartMaker = "<svg ";
// A<svg attr1="value1" attr2="value2">B</svg>C -> <svg attr="value">B
const svgHtml = text.slice(text.indexOf(svgStartMaker), text.lastIndexOf("</svg>"));
const gtPos = svgHtml.indexOf(">");
// <svg attr="value">B -> attr1="value1" attr2="value2"
const attributesText = svgHtml.slice(svgStartMaker.length, gtPos);
// <svg attr="value">B -> B
const content = svgHtml.slice(gtPos + 1);
const attributes = [];
attributesText.replace(/([\d:a-z-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi, (_, name, value, value2) => {
attributes.push({ name, value: value !== null && value !== void 0 ? value : value2 });
return "";
});
return { content, attributes };
}
function setupStyle(svgNames) {
var _a;
const id = "svg-style-keyframes";
(_a = document.querySelector(`#${id}`)) === null || _a === void 0 ? void 0 : _a.remove(); // Storybookなどだと重複して追加されるのでそれを防ぐ
// ヘッダにSVG切り替えイベント用のアニメーションを設置する
const style = document.createElement("style");
style.id = id;
// "NULL" "NONE" "HIDDEN"は非表示用の特別なキー
style.textContent = ["NULL", "NONE", "HIDDEN", ...svgNames]
.map((name) => `@keyframes svg_${name} {}`)
.join("\n");
// NOTE: SafariはCSS側でanimation-nameプロパティが当たる前に@keyframesが用意されていないとanimationstartが発火しない。
// そのため、まず@keyframesを設置してから、確実に次の描画以降でanimation-nameプロパティが当たるようにタイミングを調整する必要があります。
document.head.append(style);
}
function resolveSvgName(event) {
return (event.animationName.startsWith(exports.animationNamePrefix) &&
event.animationName.slice(exports.animationNamePrefix.length));
}
function svgNameIsEmptyType(svgName) {
return svgName === "NONE" || svgName === "HIDDEN";
}
;