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:
2026-10-18 10:17:54 +01:00
co-authored by Claude Opus 5.5
parent d19d412066
commit 11da9b89e7
148 changed files with 937 additions and 500 deletions
+176
View File
@@ -0,0 +1,176 @@
import React, { type RefObject } from "react";
import { twMerge } from "tailwind-merge";
import AceEditorModes from "./ace-editor-modes";
import type { Ace as AceAjax } from "ace-builds";
export type AceEditorComponentType = {
editorRef?: RefObject<AceAjax.Editor | undefined>;
readOnly?: boolean;
/** Function to call when Ctrl+Enter is pressed */
ctrlEnterFn?: (editor: AceAjax.Editor) => void;
content?: string;
placeholder?: string;
title?: string;
mode?: (typeof AceEditorModes)[number];
fontSize?: string;
previewMode?: boolean;
onChange?: (value: string, editor?: AceAjax.Editor) => void;
delay?: number;
wrapperProps?: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
editorOptions?: AceAjax.EditorOptions;
showLabel?: boolean;
};
let timeout: any;
/**
* # Powerful Ace Editor
* @note **NOTE** head scripts required
* @script `https://cdnjs.cloudflare.com/ajax/libs/ace/1.22.0/ace.min.js`
* @script `https://cdnjs.cloudflare.com/ajax/libs/ace/1.22.0/ext-language_tools.min.js`
*/
export default function AceEditor({
editorRef,
readOnly,
ctrlEnterFn,
content = "",
placeholder,
mode,
fontSize,
previewMode,
onChange,
delay = 500,
wrapperProps,
editorOptions,
showLabel,
title,
}: AceEditorComponentType) {
try {
const editorElementRef = React.useRef<HTMLDivElement>(undefined);
const editorRefInstance = React.useRef<AceAjax.Editor>(undefined);
const [refresh, setRefresh] = React.useState(0);
const [darkMode, setDarkMode] = React.useState(false);
const [ready, setReady] = React.useState(false);
React.useEffect(() => {
if (!ready) return;
// @ts-ignore
const ace = window.ace;
if (!ace?.edit || !editorElementRef.current) {
setTimeout(() => {
setRefresh((prev) => prev + 1);
}, 1000);
return;
}
// @ts-ignore
const editor = ace.edit(editorElementRef.current) as AceAjax.Editor;
editor.setOptions({
mode: `ace/mode/${mode ? mode : "javascript"}`,
theme: darkMode
? "ace/theme/tomorrow_night_eighties"
: "ace/theme/ace_light",
value: (() => {
try {
return JSON.stringify(JSON.parse(content), null, 4);
} catch (error) {
return content;
}
})(),
placeholder: placeholder ? placeholder : "",
enableBasicAutocompletion: true,
enableLiveAutocompletion: true,
readOnly: readOnly ? true : false,
fontSize: fontSize ? fontSize : undefined,
showLineNumbers: previewMode ? false : true,
wrap: true,
wrapMethod: "code",
...editorOptions,
});
editor.commands.addCommand({
name: "myCommand",
bindKey: { win: "Ctrl-Enter", mac: "Command-Enter" },
exec: function (editor) {
if (ctrlEnterFn) ctrlEnterFn(editor);
},
readOnly: true,
});
editor.getSession().on("change", function (e) {
if (onChange) {
clearTimeout(timeout);
setTimeout(() => {
try {
onChange(
editor.getValue(),
editorRefInstance.current,
);
} catch (error) {}
}, delay);
}
});
editorRefInstance.current = editor;
if (editorRef) editorRef.current = editor;
return function () {
editor.destroy();
};
}, [refresh, darkMode, ready, mode]);
React.useEffect(() => {
const htmlClassName = document.documentElement.className;
if (htmlClassName.match(/dark/i)) setDarkMode(true);
setTimeout(() => {
setReady(true);
}, 200);
}, []);
return (
<React.Fragment>
<div
{...wrapperProps}
className={twMerge(
"w-full h-[400px] block rounded-default",
"border border-border border-solid relative",
"dark:border-white/20",
showLabel && title ? "pt-4" : "",
wrapperProps?.className,
)}
>
{showLabel && title ? (
<label
className={twMerge(
"bg-background-light dark:bg-background-dark text-xs",
"-top-3 left-2 px-2 py-1 absolute z-10",
)}
>
{title}
</label>
) : null}
<div
ref={editorElementRef as any}
className="w-full h-full"
></div>
</div>
</React.Fragment>
);
} catch (error: any) {
return (
<React.Fragment>
<span className="m-0">
Editor Error: <b className="text-error">{error.message}</b>
</span>
</React.Fragment>
);
}
}
+22
View File
@@ -0,0 +1,22 @@
const FALLBACK_BASE_URL = "https://www.datasquirel.com/tinymce-public";
/**
* Resolves the TinyMCE base URL from public env vars. Each lookup is wrapped
* in try/catch rather than a `typeof process` check: bundlers inline the full
* `process.env.X` expression, so the global `process` may still be undefined
* in the browser even when the value is available.
*/
export default function getTinyMCEDefaultBaseUrl(): string {
let nextUrl: string | undefined;
let bunextUrl: string | undefined;
try {
nextUrl = process.env.NEXT_PUBLIC_TINYMCE_BASE_URL;
} catch {}
try {
bunextUrl = process.env.BUNEXT_PUBLIC_TINYMCE_BASE_URL;
} catch {}
return nextUrl || bunextUrl || FALLBACK_BASE_URL;
}
+242
View File
@@ -0,0 +1,242 @@
import React, { useCallback, type ComponentProps, type RefObject } from "react";
import { type Editor, type RawEditorOptions } from "./tinymce";
import { twMerge } from "tailwind-merge";
import twuiSlugToNormalText from "../../utils/slug-to-normal-text";
import Border from "../../elements/Border";
import useTinyMCE from "./useTinyMCE";
import getTinyMCEDefaultBaseUrl from "./default-base-url";
export type TinyMCEEditorProps<KeyType extends string> = {
options?: RawEditorOptions;
editorRef?: React.RefObject<Editor | null>;
setEditor?: React.Dispatch<React.SetStateAction<Editor>>;
wrapperProps?: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
wrapperWrapperProps?: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
borderProps?: ComponentProps<typeof Border>;
defaultValue?: string;
name?: KeyType;
changeHandler?: (content: string, editor?: Editor) => void;
showLabel?: boolean;
useParentCSS?: boolean;
placeholder?: string;
refreshDependencyArray?: any[];
base_url?: string;
};
/**
* # Tiny MCE Editor Component
* @className_wrapper twui-rte-wrapper
*/
export default function TinyMCEEditor<KeyType extends string>({
options,
editorRef: passedEditorRef,
setEditor: passedSetEditor,
wrapperProps,
defaultValue,
changeHandler,
wrapperWrapperProps,
borderProps,
name,
showLabel,
useParentCSS,
placeholder,
refreshDependencyArray,
base_url,
}: TinyMCEEditorProps<KeyType>) {
const { tinyMCE } = useTinyMCE({ base_url });
const editorComponentRef = React.useRef<HTMLDivElement>(null);
const editorRef: RefObject<Editor | null> =
passedEditorRef || React.useRef(null);
const EDITOR_VALUE_CHANGE_TIMEOUT = 500;
const FINAL_HEIGHT = options?.height || 500;
const [themeReady, setThemeReady] = React.useState(false);
const [ready, setReady] = React.useState(false);
const [darkMode, setDarkMode] = React.useState(false);
const [refresh, setRefresh] = React.useState(0);
const title = name ? twuiSlugToNormalText(name) : "Rich Text";
React.useEffect(() => {
if (!tinyMCE) {
return;
}
const htmlClassName = document.documentElement.className;
if (htmlClassName.match(/dark/i)) setDarkMode(true);
setTimeout(() => {
setThemeReady(true);
}, 200);
}, [tinyMCE]);
let valueTimeout: any;
const id = crypto.randomUUID();
const updateContent = useCallback((editor: Editor) => {
if (changeHandler) {
window.clearTimeout(valueTimeout);
valueTimeout = setTimeout(() => {
changeHandler(
editor.getContent(),
editorRef.current || undefined,
);
}, EDITOR_VALUE_CHANGE_TIMEOUT);
}
}, []);
React.useEffect(() => {
if (!editorComponentRef.current || !themeReady || !tinyMCE) {
return;
}
const baseUrl =
base_url ||
getTinyMCEDefaultBaseUrl();
tinyMCE.init({
height: FINAL_HEIGHT,
menubar: false,
plugins:
"advlist lists link image charmap preview anchor searchreplace visualblocks code fullscreen insertdatetime media table code help wordcount",
toolbar:
"undo redo | blocks | bold italic underline link image | align bullist numlist | outdent indent removeformat code searchreplace wordcount preview insertdatetime",
content_style: [
"body { font-family:Helvetica,Arial,sans-serif; font-size:14px; background-color: transparent }",
`.mce-content-body[data-mce-placeholder]:not(.mce-visualblocks)::before { color: ${
darkMode
? "rgba(255, 255, 255, 0.45)"
: "rgba(34, 47, 62, 0.6)"
} !important; }`,
].join("\n"),
init_instance_callback: (editor) => {
if (editorRef) {
editorRef.current = editor;
passedSetEditor?.(editor);
}
if (defaultValue) editor.setContent(defaultValue);
setReady(true);
editor.on("change", (e) => {
updateContent(editor);
});
editor.on("input", (e) => {
updateContent(editor);
});
if (useParentCSS) {
useParentStyles(editor);
}
},
base_url: baseUrl,
body_class: "twui-tinymce",
placeholder,
relative_urls: true,
remove_script_host: true,
convert_urls: false,
...options,
license_key: "gpl",
target: editorComponentRef.current,
content_css: darkMode ? "dark" : undefined,
skin: darkMode ? "oxide-dark" : undefined,
});
return function () {
if (!ready) return;
const instance = editorComponentRef.current
? tinyMCE?.get(editorComponentRef.current?.id)
: undefined;
instance?.remove();
};
}, [tinyMCE, themeReady, refresh, ...(refreshDependencyArray || [])]);
return (
<div
{...wrapperWrapperProps}
className={twMerge(
"relative w-full [&_.tox-tinymce]:!border-none",
"bg-background-light dark:bg-background-dark",
wrapperWrapperProps?.className,
)}
>
{showLabel && (
<label
className={twMerge(
"absolute z-10 -top-[7px] left-[10px] px-2 text-xs",
"bg-background-light dark:bg-background-dark text-gray-500",
"dark:text-white/80 rounded",
)}
htmlFor={id}
>
{title}
</label>
)}
<Border
{...borderProps}
className={twMerge(
"dark:border-white/30 p-0 pt-2",
borderProps?.className,
)}
>
<div
{...wrapperProps}
ref={editorComponentRef}
style={{
height:
String(FINAL_HEIGHT).replace(/[^\d]/g, "") + "px",
...wrapperProps?.style,
}}
className={twMerge(
"bg-slate-200 dark:bg-slate-700 rounded-sm w-full",
"twui-rte-wrapper",
)}
id={id}
></div>
</Border>
</div>
);
}
function useParentStyles(editor: Editor) {
const doc = editor.getDoc();
const parentStylesheets = document.styleSheets;
for (const sheet of parentStylesheets) {
try {
if (sheet.href) {
const link = doc.createElement("link");
link.rel = "stylesheet";
link.href = sheet.href;
doc.head.appendChild(link);
} else {
const rules = sheet.cssRules || sheet.rules;
if (rules) {
const style = doc.createElement("style");
for (const rule of rules) {
try {
style.appendChild(doc.createTextNode(rule.cssText));
} catch (e) {
console.warn("Could not copy CSS rule:", rule, e);
}
}
doc.head.appendChild(style);
}
}
} catch (e) {
console.warn("Error processing stylesheet:", sheet, e);
}
}
}
+3313
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
import React from "react";
import type { TinyMCE } from "./tinymce";
import getTinyMCEDefaultBaseUrl from "./default-base-url";
let interval: any;
type Params = {
base_url?: string;
};
export default function useTinyMCE(params?: Params) {
const [tinyMCE, setTinyMCE] = React.useState<TinyMCE>();
const [refresh, setRefresh] = React.useState(0);
const [scriptLoaded, setScriptLoaded] = React.useState(false);
React.useEffect(() => {
if (refresh >= 5) return;
const clientWindow = window as Window & { tinymce?: TinyMCE };
if (clientWindow.tinymce) {
setScriptLoaded(true);
return;
}
const script = document.createElement("script");
const baseUrl =
params?.base_url ||
getTinyMCEDefaultBaseUrl();
script.src = `${baseUrl}/tinymce.min.js`;
script.async = true;
script.onload = () => {
setScriptLoaded(true);
};
document.head.appendChild(script);
}, [refresh]);
React.useEffect(() => {
if (!scriptLoaded) return;
const clientWindow = window as Window & { tinymce?: TinyMCE };
let tinyMCE = clientWindow.tinymce;
if (tinyMCE) {
setTinyMCE(tinyMCE);
} else {
setRefresh((prev) => prev + 1);
}
}, [scriptLoaded]);
return { tinyMCE };
}
+125
View File
@@ -0,0 +1,125 @@
const AceEditorModes = [
"abap",
"abc",
"actionscript",
"ada",
"apache_conf",
"asciidoc",
"assembly_x86",
"autohotkey",
"batchfile",
"c9search",
"c_cpp",
"cirru",
"clojure",
"cobol",
"coffee",
"coldfusion",
"csharp",
"css",
"curly",
"d",
"dart",
"diff",
"dockerfile",
"dot",
"dummy",
"dummysyntax",
"eiffel",
"ejs",
"elixir",
"elm",
"erlang",
"forth",
"ftl",
"gcode",
"gherkin",
"gitignore",
"glsl",
"golang",
"groovy",
"haml",
"handlebars",
"haskell",
"haxe",
"html",
"html_ruby",
"ini",
"io",
"jack",
"jade",
"java",
"javascript",
"json",
"jsoniq",
"jsp",
"jsx",
"julia",
"latex",
"less",
"liquid",
"lisp",
"livescript",
"logiql",
"lsl",
"lua",
"luapage",
"lucene",
"makefile",
"markdown",
"mask",
"matlab",
"mel",
"mushcode",
"mysql",
"nix",
"objectivec",
"ocaml",
"pascal",
"perl",
"pgsql",
"php",
"powershell",
"praat",
"prolog",
"properties",
"protobuf",
"python",
"r",
"rdoc",
"rhtml",
"ruby",
"rust",
"sass",
"scad",
"scala",
"scheme",
"scss",
"sh",
"sjs",
"smarty",
"snippets",
"soy_template",
"space",
"sql",
"stylus",
"svg",
"tcl",
"tex",
"text",
"textile",
"toml",
"twig",
"typescript",
"vala",
"vbscript",
"velocity",
"verilog",
"vhdl",
"xml",
"xquery",
"yaml",
"shell",
] as const;
export default AceEditorModes;