UNPKG

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
#!/usr/bin/env node 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, {}));