rn-pixel-perfect-cli
Version:
A simple tool for pixel-perfect layouts, allowing you to compare design screens with your app's implementation.
198 lines (197 loc) • 8.4 kB
JavaScript
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useCallback, useEffect, useRef, useState } from 'react';
import { render, Text, Box, useInput } from 'ink';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as util from 'node:util';
import { WebSocketServer } from 'ws';
import { createStore } from 'zustand/vanilla';
import { useStore } from 'zustand';
import { clearTimeout } from 'node:timers';
const { values } = util.parseArgs({
args: process.argv,
options: {
folder: {
type: 'string',
short: 'f',
},
port: {
type: 'string',
short: 'p',
},
},
allowPositionals: true,
});
const CONSTANTS = {
folder: values.folder ?? 'ui',
port: values.port ? Number(values.port) : 3210,
};
const ConnectionsStore = createStore((set) => ({
devices: [],
addConnection: (device) => set(({ devices }) => ({ devices: [...devices, device] })),
removeConnection: (connection) => set(({ devices }) => ({
devices: devices.filter((device) => device.connection !== connection),
})),
}));
const ImagesStore = createStore((set) => ({
selected: 0,
images: [],
hidden: false,
scroll: false,
setSelected: (selected) => set({ selected }),
setImages: (images) => set({ images }),
toggleHidden: () => set(({ hidden }) => ({ hidden: !hidden })),
toggleScroll: () => set(({ scroll }) => ({ scroll: !scroll })),
}));
function uint8ArrayToBase64(uint8Array) {
let binary = '';
const len = uint8Array.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(uint8Array[i]);
}
return btoa(binary);
}
const validateMessage = (data) => {
try {
const parsed = JSON.parse(String(data));
if ('type' in parsed && typeof parsed.type === 'string')
return parsed;
return null;
}
catch {
return null;
}
};
const wss = new WebSocketServer({ port: CONSTANTS.port });
wss.on('connection', function connection(ws) {
ws.on('error', console.error);
ws.on('message', function message(data) {
const msg = validateMessage(data);
switch (msg?.type) {
case 'register': {
const { selected, images, hidden, scroll } = ImagesStore.getState();
if (images[selected])
setImage(images[selected], ws);
if (hidden)
sendServerMessage({ type: 'setHidden', value: hidden }, ws);
if (scroll)
sendServerMessage({ type: 'setScroll', value: scroll }, ws);
return ConnectionsStore.getState().addConnection({
name: msg?.name,
connection: ws,
});
}
}
});
ws.on('close', function close() {
ConnectionsStore.getState().removeConnection(ws);
});
});
const sendServerMessage = (message, target) => {
const formated = JSON.stringify(message);
if (target)
return target.send(formated);
ConnectionsStore.getState().devices.forEach(({ connection }) => {
connection.send(formated);
});
};
const setImage = async (name, target) => {
try {
const file = await fs.promises.readFile(path.join(CONSTANTS.folder, name));
const imageBase64 = uint8ArrayToBase64(new Uint8Array(file));
sendServerMessage({
type: 'setImage',
image: `data:image/png;base64,${imageBase64}`,
}, target);
}
catch {
console.error(`Failed to load file: ${name}`);
}
};
const setHidden = (value) => sendServerMessage({
type: 'setHidden',
value,
});
const setScroll = (value) => sendServerMessage({
type: 'setScroll',
value,
});
const changeOpacity = (plus) => sendServerMessage({ type: 'changeOpacity', value: plus ? 0.2 : -0.2 });
const HelpItem = ({ title, value }) => {
return (_jsxs(Box, { children: [_jsx(Box, { width: 5, children: _jsx(Text, { color: "blue", bold: true, children: title }) }), _jsx(Text, { color: "gray", children: value })] }));
};
const FileSelector = () => {
const { images, setImages, hidden, scroll, toggleHidden, toggleScroll, selected, setSelected, } = useStore(ImagesStore);
const timeout = useRef();
const [error, setError] = useState(null);
useInput((char, key) => {
const listSize = images.length;
const prev = ImagesStore.getState().selected;
if (key.rightArrow) {
let newVal = (prev ?? 0) + 1;
if (listSize <= newVal)
newVal = 0;
return setSelected(newVal); // next screen
}
else if (key.leftArrow) {
let newVal = (prev ?? 0) - 1;
if (newVal < 0)
newVal = listSize - 1;
return setSelected(newVal); // prev screen
}
else if (key.upArrow)
return changeOpacity(true); // plus opacity
else if (key.downArrow)
return changeOpacity(false); // minus opacity
else if (char === 'H' || char === 'h')
toggleHidden(); // toggle hidden
else if (char === 'S' || char === 's')
toggleScroll(); // toggle scroll
});
const readFolder = useCallback(() => {
fs.promises
.readdir(CONSTANTS.folder)
.then((list) => {
const imgList = list.filter((item) => new RegExp(/\.(jpg|jpeg|png|gif|bmp|webp)$/i).test(item));
setImages(imgList);
if (imgList[0])
setSelected(0);
})
.catch((e) => {
setError(e.message ?? `Could not open ${CONSTANTS.folder} folder`);
});
}, [setImages, setSelected]);
useEffect(() => {
if (timeout.current)
clearTimeout(timeout.current);
timeout.current = setTimeout(() => {
if (images[selected])
setImage(images[selected]);
}, 200);
}, [selected, images]);
useEffect(() => {
setHidden(hidden);
}, [hidden]);
useEffect(() => {
setScroll(scroll);
}, [scroll]);
useEffect(() => {
if (!fs.existsSync(CONSTANTS.folder)) {
return setError(`Folder ./${CONSTANTS.folder} not found`);
}
readFolder();
const watcher = fs.watch(CONSTANTS.folder, () => {
readFolder();
});
return () => {
watcher.close();
};
}, [readFolder]);
const devices = useStore(ConnectionsStore, (store) => store.devices);
return (_jsxs(Box, { flexDirection: "column", paddingTop: 1, children: [_jsxs(Box, { children: [_jsxs(Box, { flexGrow: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "blue", children: "Devices: [" }), devices.map((device, i) => (_jsxs(Text, { children: [device.name, devices.length - 1 !== i && ','] }, i))), _jsx(Text, { color: "blue", children: "]" })] }), _jsxs(Box, { children: [_jsx(Text, { color: "blue", children: "Folder: " }), _jsx(Text, { children: CONSTANTS.folder })] }), _jsxs(Box, { children: [_jsx(Text, { color: "blue", children: "WS on: " }), _jsxs(Text, { children: ["ws://localhost:", CONSTANTS.port] })] })] }), _jsxs(Box, { flexGrow: 1, gap: 6, children: [_jsxs(Box, { flexDirection: "column", children: [_jsx(HelpItem, { title: "\u2190", value: "Prev screen" }), _jsx(HelpItem, { title: "\u2192", value: "Next screen" })] }), _jsxs(Box, { flexDirection: "column", children: [_jsx(HelpItem, { title: "\u2191", value: "Increase opacity" }), _jsx(HelpItem, { title: "\u2193", value: "Decrease opacity" })] }), _jsxs(Box, { flexDirection: "column", children: [_jsx(HelpItem, { title: "s", value: scroll ? 'Scroll on ' : 'Scroll off' }), _jsx(HelpItem, { title: "h", value: hidden ? 'Show Ui' : 'Hide Ui' })] })] })] }), _jsx(Box, { flexWrap: "wrap", children: images.map((image, index) => {
const isSelected = index === selected;
return (_jsx(Box, { width: "12.5%", borderColor: isSelected && !hidden ? 'greenBright' : 'gray', borderStyle: "round", children: _jsx(Text, { color: isSelected ? 'greenBright' : 'black', children: image }) }, image));
}) }), error && _jsx(Text, { color: "redBright", children: error })] }));
};
render(_jsx(FileSelector, {}));