Package library as @moduletrace/twui npm package
- Move components/ to src/ and compile to dist/ with tsc - Add root barrel export plus deep-import subpath exports and base.css export - Post-compile step: copy base.css, add .js import extensions, mark client modules - Add publish.sh and .npmrc for the Gitea npm registry - Fix type errors, guard TinyMCE env access, drop ace-builds type dependency - Rename clashing props type names, remove Next.js scaffolding and unused deps Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import React from "react";
|
||||
|
||||
type Param = {
|
||||
/**
|
||||
* Custom Event Name
|
||||
*/
|
||||
name: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Dispatch Custom Event
|
||||
*/
|
||||
export default function useCustomEventDispatch<
|
||||
T extends { [key: string]: any } = { [key: string]: any }
|
||||
>({ name }: Param) {
|
||||
const dispatchCustomEvent = React.useCallback((value: T | string) => {
|
||||
let dataParsed = typeof value == "object" ? value : undefined;
|
||||
const str = typeof value == "string" ? value : undefined;
|
||||
|
||||
if (str) {
|
||||
try {
|
||||
dataParsed = JSON.parse(str);
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
const event = new CustomEvent(name, {
|
||||
detail: {
|
||||
data: dataParsed,
|
||||
message: str,
|
||||
},
|
||||
});
|
||||
|
||||
window.dispatchEvent(event);
|
||||
}, []);
|
||||
|
||||
return { dispatchCustomEvent };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from "react";
|
||||
|
||||
type Param = {
|
||||
/**
|
||||
* Custom Event Name
|
||||
*/
|
||||
name: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Listen For Custom Event
|
||||
*/
|
||||
export default function useCustomEventListener<
|
||||
T extends { [key: string]: any } = { [key: string]: any }
|
||||
>({ name }: Param) {
|
||||
const [data, setData] = React.useState<T | undefined>(undefined);
|
||||
const [message, setMessage] = React.useState<string | undefined>(undefined);
|
||||
|
||||
const dataEventListenerCallback = React.useCallback((e: Event) => {
|
||||
const customEvent = e as CustomEvent;
|
||||
const eventPayloadMessage = customEvent.detail.message as
|
||||
| string
|
||||
| undefined;
|
||||
const eventPayloadData = customEvent.detail.data as T | undefined;
|
||||
|
||||
if (eventPayloadMessage) setMessage(eventPayloadMessage);
|
||||
if (eventPayloadData) setData(eventPayloadData);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
window.addEventListener(name, dataEventListenerCallback, false);
|
||||
|
||||
return function () {
|
||||
window.removeEventListener(name, dataEventListenerCallback);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { data, message };
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import React, { useRef } from "react";
|
||||
|
||||
type Param = {
|
||||
elementRef?: React.RefObject<Element | undefined>;
|
||||
className?: string;
|
||||
elId?: string;
|
||||
options?: IntersectionObserverInit;
|
||||
removeIntersected?: boolean;
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
export default function useIntersectionObserver({
|
||||
elementRef,
|
||||
className,
|
||||
options,
|
||||
removeIntersected,
|
||||
delay,
|
||||
elId,
|
||||
}: Param) {
|
||||
let timeoutRef = useRef<any>(null);
|
||||
|
||||
const [isIntersecting, setIsIntersecting] = React.useState(false);
|
||||
const [refresh, setRefresh] = React.useState(0);
|
||||
|
||||
const observerTriggerDelay = delay || 200;
|
||||
|
||||
const observerCallback: IntersectionObserverCallback = React.useCallback(
|
||||
(entries, observer) => {
|
||||
const entry = entries[0];
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
|
||||
if (entry?.isIntersecting) {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setIsIntersecting(true);
|
||||
|
||||
if (removeIntersected) {
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
}, observerTriggerDelay);
|
||||
} else {
|
||||
setIsIntersecting(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const element = elId
|
||||
? document.getElementById(elId)
|
||||
: elementRef?.current;
|
||||
const elements = className
|
||||
? document.querySelectorAll(`.${className}`)
|
||||
: null;
|
||||
|
||||
if (!element && !className && refresh < 5) {
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
setRefresh(refresh + 1);
|
||||
}, 2000);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(observerCallback, {
|
||||
rootMargin: "0px 0px 0px 0px",
|
||||
...options,
|
||||
});
|
||||
|
||||
if (elements) {
|
||||
elements.forEach((el) => {
|
||||
observer.observe(el);
|
||||
});
|
||||
} else if (element) {
|
||||
observer.observe(element);
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
return { isIntersecting };
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from "react";
|
||||
|
||||
export type UseLocalStorageParam<
|
||||
T extends { [key: string]: any } = { [key: string]: any }
|
||||
> = {
|
||||
key: keyof T;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Use Local Storage
|
||||
*/
|
||||
export default function useLocalStorage<
|
||||
T extends Record<string, any> | undefined = undefined
|
||||
>(param?: UseLocalStorageParam) {
|
||||
const [data, setData] =
|
||||
React.useState<T extends undefined ? string | null : T>();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (param?.key) {
|
||||
const value = localStorage.getItem(param.key as string);
|
||||
try {
|
||||
const jsonValue = JSON.parse(value || "");
|
||||
setData(jsonValue as any);
|
||||
} catch (error) {
|
||||
setData(value as any);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { data };
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
|
||||
type Params = {
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
let timeout: any;
|
||||
|
||||
export default function useReady(params?: Params) {
|
||||
const [ready, setReady] = React.useState(false);
|
||||
|
||||
const finalTimeout = params?.timeout || 300;
|
||||
|
||||
React.useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
window.clearTimeout(timeout);
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
setReady(true);
|
||||
}, finalTimeout);
|
||||
});
|
||||
|
||||
return function () {
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { ready };
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from "react";
|
||||
|
||||
export type TWUIErrorType = {
|
||||
msg?: string;
|
||||
};
|
||||
|
||||
type Params = {
|
||||
initialLoading?: boolean;
|
||||
initialReady?: boolean;
|
||||
initialOpen?: boolean;
|
||||
};
|
||||
|
||||
export type TWUIStatusType = {
|
||||
msg?: string;
|
||||
error?: boolean;
|
||||
success?: boolean;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export default function useStatus(params?: Params) {
|
||||
const [refresh, setRefresh] = React.useState(0);
|
||||
const [loading, setLoading] = React.useState(
|
||||
params?.initialLoading || false,
|
||||
);
|
||||
const [status, setStatus] = React.useState<TWUIStatusType>();
|
||||
const [ready, setReady] = React.useState(params?.initialReady || false);
|
||||
const [open, setOpen] = React.useState(params?.initialOpen || false);
|
||||
const [error, setError] = React.useState<TWUIErrorType>();
|
||||
|
||||
return {
|
||||
refresh,
|
||||
setRefresh,
|
||||
loading,
|
||||
setLoading,
|
||||
status,
|
||||
setStatus,
|
||||
ready,
|
||||
setReady,
|
||||
open,
|
||||
setOpen,
|
||||
error,
|
||||
setError,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import React, { useRef } from "react";
|
||||
|
||||
export type UseWebsocketHookParams = {
|
||||
debounce?: number;
|
||||
url: string;
|
||||
disableReconnect?: boolean;
|
||||
/** Interval to ping the websocket. So that the connection doesn't go down. Default 30000ms (30 seconds) */
|
||||
keepAliveDuration?: number;
|
||||
/** Interval in ms to force-refresh the connection */
|
||||
refreshConnection?: number;
|
||||
};
|
||||
|
||||
export const WebSocketEventNames = ["wsDataEvent", "wsMessageEvent"] as const;
|
||||
|
||||
/**
|
||||
* # Use Websocket Hook
|
||||
* @event wsDataEvent Listen for event named `wsDataEvent` on `window` to receive Data events
|
||||
* @event wsMessageEvent Listen for event named `wsMessageEvent` on `window` to receive Message events
|
||||
*
|
||||
* @example window.addEventListener("wsDataEvent", (e)=>{
|
||||
* console.log(e.detail.data) // type object
|
||||
* })
|
||||
* @example window.addEventListener("wsMessageEvent", (e)=>{
|
||||
* console.log(e.detail.message) // type string
|
||||
* })
|
||||
*/
|
||||
export default function useWebSocket<
|
||||
T extends { [key: string]: any } = { [key: string]: any }
|
||||
>({
|
||||
url,
|
||||
debounce,
|
||||
disableReconnect,
|
||||
keepAliveDuration,
|
||||
refreshConnection,
|
||||
}: UseWebsocketHookParams) {
|
||||
const DEBOUNCE = debounce || 500;
|
||||
const KEEP_ALIVE_DURATION = keepAliveDuration || 1000 * 30;
|
||||
const KEEP_ALIVE_MESSAGE = "twui::ping";
|
||||
|
||||
const tries = useRef(0);
|
||||
|
||||
// Refs to avoid stale closures in callbacks
|
||||
const urlRef = useRef(url);
|
||||
const disableReconnectRef = useRef(disableReconnect);
|
||||
const keepAliveDurationRef = useRef(KEEP_ALIVE_DURATION);
|
||||
|
||||
React.useEffect(() => {
|
||||
urlRef.current = url;
|
||||
disableReconnectRef.current = disableReconnect;
|
||||
keepAliveDurationRef.current = KEEP_ALIVE_DURATION;
|
||||
});
|
||||
|
||||
const msgInterval = useRef<any>(null);
|
||||
const sendInterval = useRef<any>(null);
|
||||
const keepAliveInterval = useRef<any>(null);
|
||||
const refreshInterval = useRef<any>(null);
|
||||
const reconnectTimeout = useRef<any>(null);
|
||||
|
||||
const [socket, setSocket] = React.useState<WebSocket | undefined>(undefined);
|
||||
const socketRef = useRef<WebSocket | undefined>(undefined);
|
||||
|
||||
const messageQueueRef = React.useRef<string[]>([]);
|
||||
const sendMessageQueueRef = React.useRef<string[]>([]);
|
||||
|
||||
/**
|
||||
* # Dispatch Custom Event
|
||||
*/
|
||||
const dispatchCustomEvent = React.useCallback(
|
||||
(evtName: (typeof WebSocketEventNames)[number], value: string | T) => {
|
||||
const event = new CustomEvent(evtName, {
|
||||
detail: {
|
||||
data: value,
|
||||
message: value,
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* # Connect to Websocket
|
||||
*/
|
||||
const connect = React.useCallback(() => {
|
||||
const currentUrl = urlRef.current;
|
||||
const domain = window.location.origin;
|
||||
const wsURL = currentUrl.startsWith("ws")
|
||||
? currentUrl
|
||||
: domain.replace(/^http/, "ws") + ("/" + currentUrl).replace(/\/\//g, "/");
|
||||
|
||||
if (!wsURL) return;
|
||||
|
||||
const ws = new WebSocket(wsURL);
|
||||
|
||||
ws.onerror = () => {
|
||||
console.log(`Websocket ERROR:`);
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
messageQueueRef.current.push(ev.data);
|
||||
};
|
||||
|
||||
ws.onopen = () => {
|
||||
window.clearInterval(keepAliveInterval.current);
|
||||
|
||||
keepAliveInterval.current = window.setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(KEEP_ALIVE_MESSAGE);
|
||||
}
|
||||
}, keepAliveDurationRef.current);
|
||||
|
||||
tries.current = 0;
|
||||
socketRef.current = ws;
|
||||
setSocket(ws);
|
||||
console.log(`Websocket connected to ${wsURL}`);
|
||||
};
|
||||
|
||||
ws.onclose = (ev) => {
|
||||
console.log("Websocket closed!", {
|
||||
code: ev.code,
|
||||
reason: ev.reason,
|
||||
wasClean: ev.wasClean,
|
||||
});
|
||||
|
||||
window.clearInterval(keepAliveInterval.current);
|
||||
socketRef.current = undefined;
|
||||
setSocket(undefined);
|
||||
|
||||
if (disableReconnectRef.current) return;
|
||||
|
||||
if (tries.current >= 3) {
|
||||
console.log("Max reconnect attempts reached.");
|
||||
return;
|
||||
}
|
||||
|
||||
tries.current += 1;
|
||||
const backoff = Math.min(1000 * 2 ** tries.current, 30000);
|
||||
console.log(`Attempting to reconnect in ${backoff}ms... (attempt ${tries.current})`);
|
||||
reconnectTimeout.current = window.setTimeout(connect, backoff);
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* # Initial Connection
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(reconnectTimeout.current);
|
||||
window.clearInterval(keepAliveInterval.current);
|
||||
window.clearInterval(refreshInterval.current);
|
||||
socketRef.current?.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* # Refresh Connection Interval
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (!refreshConnection) return;
|
||||
|
||||
refreshInterval.current = window.setInterval(() => {
|
||||
console.log("Refreshing WebSocket connection...");
|
||||
window.clearTimeout(reconnectTimeout.current);
|
||||
socketRef.current?.close();
|
||||
tries.current = 0;
|
||||
connect();
|
||||
}, refreshConnection);
|
||||
|
||||
return () => window.clearInterval(refreshInterval.current);
|
||||
}, [refreshConnection]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
sendInterval.current = setInterval(handleSendMessageQueue, DEBOUNCE);
|
||||
msgInterval.current = setInterval(handleReceivedMessageQueue, DEBOUNCE);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(sendInterval.current);
|
||||
window.clearInterval(msgInterval.current);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
/**
|
||||
* Received Message Queue Handler
|
||||
*/
|
||||
const handleReceivedMessageQueue = React.useCallback(() => {
|
||||
try {
|
||||
const msg = messageQueueRef.current.shift();
|
||||
|
||||
if (!msg) return;
|
||||
|
||||
const jsonData = JSON.parse(msg);
|
||||
dispatchCustomEvent("wsMessageEvent", msg);
|
||||
dispatchCustomEvent("wsDataEvent", jsonData);
|
||||
} catch (error) {
|
||||
console.log("Unable to parse string. Returning string.");
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Send Message Queue Handler
|
||||
*/
|
||||
const handleSendMessageQueue = React.useCallback(() => {
|
||||
const ws = socketRef.current;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
window.clearInterval(sendInterval.current);
|
||||
return;
|
||||
}
|
||||
|
||||
const newMessage = sendMessageQueueRef.current.shift();
|
||||
if (!newMessage) return;
|
||||
|
||||
ws.send(newMessage);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* # Send Data Function
|
||||
*/
|
||||
const sendData = React.useCallback((data: T) => {
|
||||
try {
|
||||
const queueItemJSON = JSON.stringify(data);
|
||||
|
||||
const existingQueue = sendMessageQueueRef.current.find(
|
||||
(q) => q === queueItemJSON
|
||||
);
|
||||
if (!existingQueue) {
|
||||
sendMessageQueueRef.current.push(queueItemJSON);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.log("Error Sending socket message", error.message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { socket, sendData };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from "react";
|
||||
import { WebSocketEventNames } from "./useWebSocket";
|
||||
|
||||
type Param = {
|
||||
listener?: (typeof WebSocketEventNames)[number];
|
||||
};
|
||||
|
||||
/**
|
||||
* # Use Websocket Data Event Handler Hook
|
||||
*/
|
||||
export default function useWebSocketEventHandler<
|
||||
T extends { [key: string]: any } = { [key: string]: any }
|
||||
>(param?: Param) {
|
||||
const [data, setData] = React.useState<T | undefined>(undefined);
|
||||
const [message, setMessage] = React.useState<string | undefined>(undefined);
|
||||
|
||||
React.useEffect(() => {
|
||||
const dataEventListenerCallback = (e: Event) => {
|
||||
const customEvent = e as CustomEvent;
|
||||
const data = customEvent.detail.data as T | undefined;
|
||||
const __msg = customEvent.detail.message as string | undefined;
|
||||
|
||||
if (data) setData(data);
|
||||
if (__msg && typeof __msg == "string") setMessage(__msg);
|
||||
};
|
||||
|
||||
const messageEventName: (typeof WebSocketEventNames)[number] =
|
||||
param?.listener || "wsDataEvent";
|
||||
window.addEventListener(messageEventName, dataEventListenerCallback);
|
||||
|
||||
return function () {
|
||||
window.removeEventListener(
|
||||
messageEventName,
|
||||
dataEventListenerCallback
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { data, message };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export default function useWindowFocus() {
|
||||
const [isWindowFocused, setIsWindowFocused] = useState(false);
|
||||
|
||||
const windowFocusCb = useCallback(() => {
|
||||
setIsWindowFocused(true);
|
||||
}, []);
|
||||
|
||||
const windowBlurCb = useCallback(() => {
|
||||
setIsWindowFocused(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("focus", windowFocusCb);
|
||||
window.addEventListener("blur", windowBlurCb);
|
||||
return function () {
|
||||
window.removeEventListener("focus", windowFocusCb);
|
||||
window.removeEventListener("blur", windowBlurCb);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { isWindowFocused };
|
||||
}
|
||||
Reference in New Issue
Block a user