hazo_ui 3.1.3 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGE_LOG.md CHANGED
@@ -5,6 +5,42 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## v3.2.0 — 2026-05-31
9
+
10
+ **New:** `MarkdownEditor` — a generic, SSR-safe Markdown/MDX editor.
11
+
12
+ hazo_ui is now the home of *all* editors: Tiptap (`HazoUiRte`) for WYSIWYG, and
13
+ the new `MarkdownEditor` for Markdown/MDX authoring. It wraps
14
+ `@uiw/react-md-editor` but stays framework-agnostic — it lazy-loads the
15
+ underlying editor on the client only (no `next/dynamic`), so it is safe to
16
+ render in any SSR framework. It knows nothing about any specific domain; host
17
+ packages inject their own toolbar embeds, image-upload handler, and an optional
18
+ custom preview renderer (e.g. an MDX renderer).
19
+
20
+ ```ts
21
+ import { MarkdownEditor, type MarkdownEmbed } from "hazo_ui";
22
+
23
+ const embeds: MarkdownEmbed[] = [
24
+ { name: "youtube", label: "YT", snippet: '<YouTube id="" />' },
25
+ { name: "callout", label: "Note",
26
+ snippet: (sel) => `<Callout type="info">\n${sel}\n</Callout>` },
27
+ ];
28
+
29
+ <MarkdownEditor
30
+ value={value}
31
+ onChange={setValue}
32
+ embeds={embeds}
33
+ onImageUpload={(file) => uploadAndReturnUrl(file)} // paste-to-upload
34
+ renderPreview={(src) => <MyMdxPreview source={src} />} // optional
35
+ />
36
+ ```
37
+
38
+ The editor's CSS is resolved by the consumer's bundler (it is externalized in
39
+ the build), so the `@uiw` styles only ship when `MarkdownEditor` is actually
40
+ imported. The first consumer is `hazo_blog`.
41
+
42
+ New dependency: `@uiw/react-md-editor@^4.1.0`.
43
+
8
44
  ## v3.1.3 — 2026-05-30
9
45
 
10
46
  **New:** `hazo_ui/utils` sub-export carrying pure helpers without the
package/README.md CHANGED
@@ -5,9 +5,11 @@ A set of UI components for common interaction elements in a SaaS app.
5
5
  ## Installation
6
6
 
7
7
  ```bash
8
- npm install hazo_ui
8
+ npm install hazo_ui hazo_core
9
9
  ```
10
10
 
11
+ `hazo_core` is a required peer dependency (used for the logger, correlation-ID provider, and shared utilities). `react` and `react-dom` are also peer dependencies — install them if your app doesn't already have them.
12
+
11
13
  ## Quick Setup (Required)
12
14
 
13
15
  hazo_ui uses Tailwind CSS and CSS variables for styling. Follow these two steps:
package/dist/index.cjs CHANGED
@@ -55,6 +55,9 @@ var lu = require('react-icons/lu');
55
55
  var rx = require('react-icons/rx');
56
56
  var core = require('@tiptap/core');
57
57
  var TabsPrimitive = require('@radix-ui/react-tabs');
58
+ var reactMdEditor = require('@uiw/react-md-editor');
59
+ require('@uiw/react-md-editor/markdown-editor.css');
60
+ require('@uiw/react-markdown-preview/markdown.css');
58
61
  var Suggestion = require('@tiptap/suggestion');
59
62
  var state = require('@tiptap/pm/state');
60
63
  var reactDom = require('react-dom');
@@ -4541,6 +4544,165 @@ var HazoUiRte = ({
4541
4544
  );
4542
4545
  };
4543
4546
  HazoUiRte.displayName = "HazoUiRte";
4547
+ var MdEditorLazy = React26.lazy(() => import('@uiw/react-md-editor'));
4548
+ var EDITOR_TEXTAREA_SELECTOR = ".w-md-editor-text-input";
4549
+ function MarkdownEditor({
4550
+ value,
4551
+ onChange,
4552
+ height = 600,
4553
+ placeholder = "Write in Markdown\u2026",
4554
+ className,
4555
+ colorMode = "light",
4556
+ preview = "edit",
4557
+ embeds,
4558
+ extraCommands,
4559
+ onImageUpload,
4560
+ renderPreview,
4561
+ showPreview = true
4562
+ }) {
4563
+ const [mounted, setMounted] = React26.useState(false);
4564
+ React26.useEffect(() => setMounted(true), []);
4565
+ const valueRef = React26.useRef(value);
4566
+ valueRef.current = value;
4567
+ const wrapperRef = React26.useRef(null);
4568
+ const [uploading, setUploading] = React26.useState(false);
4569
+ const [error, setError] = React26.useState("");
4570
+ function insertAtCursor(text) {
4571
+ const textarea = wrapperRef.current?.querySelector(
4572
+ EDITOR_TEXTAREA_SELECTOR
4573
+ );
4574
+ const current = valueRef.current;
4575
+ if (!textarea) {
4576
+ const sep = current === "" || current.endsWith("\n") ? "" : "\n";
4577
+ onChange(current + sep + text + "\n");
4578
+ return;
4579
+ }
4580
+ const start = textarea.selectionStart ?? current.length;
4581
+ const end = textarea.selectionEnd ?? start;
4582
+ onChange(current.slice(0, start) + text + current.slice(end));
4583
+ }
4584
+ const embedCommands = React26.useMemo(() => {
4585
+ return (embeds ?? []).map((embed) => ({
4586
+ name: embed.name,
4587
+ keyCommand: embed.name,
4588
+ buttonProps: { "aria-label": embed.label, title: embed.label },
4589
+ icon: /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-xs font-semibold", children: embed.icon ?? embed.label }),
4590
+ execute: () => {
4591
+ const textarea = wrapperRef.current?.querySelector(
4592
+ EDITOR_TEXTAREA_SELECTOR
4593
+ );
4594
+ const current = valueRef.current;
4595
+ const start = textarea?.selectionStart ?? current.length;
4596
+ const end = textarea?.selectionEnd ?? start;
4597
+ const selected = current.slice(start, end);
4598
+ const snippet = typeof embed.snippet === "function" ? embed.snippet(selected) : embed.snippet;
4599
+ insertAtCursor(snippet);
4600
+ }
4601
+ }));
4602
+ }, [embeds]);
4603
+ const editorCommands = React26.useMemo(() => {
4604
+ const defaults = reactMdEditor.commands.getCommands();
4605
+ const extras = [...embedCommands, ...extraCommands ?? []];
4606
+ if (extras.length === 0) return defaults;
4607
+ const imageIndex = defaults.findIndex((c) => c.name === "image");
4608
+ if (imageIndex < 0) return [...defaults, ...extras];
4609
+ const next = [...defaults];
4610
+ next.splice(imageIndex + 1, 0, ...extras);
4611
+ return next;
4612
+ }, [embedCommands, extraCommands]);
4613
+ async function handlePaste(e) {
4614
+ if (!onImageUpload) return;
4615
+ const items = e.clipboardData?.items;
4616
+ if (!items) return;
4617
+ const imageItem = Array.from(items).find(
4618
+ (it) => it.kind === "file" && it.type.startsWith("image/")
4619
+ );
4620
+ if (!imageItem) return;
4621
+ const file = imageItem.getAsFile();
4622
+ if (!file) return;
4623
+ e.preventDefault();
4624
+ const textarea = e.currentTarget;
4625
+ const start = textarea.selectionStart;
4626
+ const end = textarea.selectionEnd;
4627
+ const current = valueRef.current;
4628
+ const before = current.slice(0, start);
4629
+ const after = current.slice(end);
4630
+ onChange(before + "![uploading\u2026]()" + after);
4631
+ setUploading(true);
4632
+ setError("");
4633
+ try {
4634
+ const url = await onImageUpload(file);
4635
+ if (!url) {
4636
+ setError("Upload failed.");
4637
+ onChange(before + after);
4638
+ return;
4639
+ }
4640
+ onChange(before + `![](${url})` + after);
4641
+ } catch {
4642
+ setError("Upload failed.");
4643
+ onChange(before + after);
4644
+ } finally {
4645
+ setUploading(false);
4646
+ }
4647
+ }
4648
+ const useCustomPreview = typeof renderPreview === "function";
4649
+ const effectivePreview = useCustomPreview ? "edit" : preview;
4650
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: cn("w-full", className), children: [
4651
+ /* @__PURE__ */ jsxRuntime.jsxs(
4652
+ "div",
4653
+ {
4654
+ className: "flex gap-4",
4655
+ "data-color-mode": colorMode,
4656
+ ref: wrapperRef,
4657
+ children: [
4658
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: useCustomPreview && showPreview ? "flex-1 min-w-0" : "w-full", children: mounted ? /* @__PURE__ */ jsxRuntime.jsx(
4659
+ React26.Suspense,
4660
+ {
4661
+ fallback: /* @__PURE__ */ jsxRuntime.jsx(
4662
+ "div",
4663
+ {
4664
+ className: "flex items-center justify-center rounded-md border border-border bg-muted/30 text-sm text-muted-foreground",
4665
+ style: { height },
4666
+ children: "Loading editor\u2026"
4667
+ }
4668
+ ),
4669
+ children: /* @__PURE__ */ jsxRuntime.jsx(
4670
+ MdEditorLazy,
4671
+ {
4672
+ value,
4673
+ onChange: (v) => onChange(v ?? ""),
4674
+ height,
4675
+ preview: effectivePreview,
4676
+ visibleDragbar: false,
4677
+ commands: editorCommands,
4678
+ textareaProps: { placeholder, onPaste: handlePaste }
4679
+ }
4680
+ )
4681
+ }
4682
+ ) : /* @__PURE__ */ jsxRuntime.jsx(
4683
+ "div",
4684
+ {
4685
+ className: "flex items-center justify-center rounded-md border border-border bg-muted/30 text-sm text-muted-foreground",
4686
+ style: { height },
4687
+ children: "Loading editor\u2026"
4688
+ }
4689
+ ) }),
4690
+ useCustomPreview && showPreview && /* @__PURE__ */ jsxRuntime.jsx(
4691
+ "div",
4692
+ {
4693
+ className: "flex-1 min-w-0 overflow-auto rounded-md border border-border bg-background p-6",
4694
+ style: { height },
4695
+ children: renderPreview(value)
4696
+ }
4697
+ )
4698
+ ]
4699
+ }
4700
+ ),
4701
+ uploading && /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-2 text-xs text-muted-foreground", children: "Uploading image\u2026" }),
4702
+ error && /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-2 text-xs text-destructive", children: error })
4703
+ ] });
4704
+ }
4705
+ MarkdownEditor.displayName = "MarkdownEditor";
4544
4706
  var generate_command_id = () => {
4545
4707
  return `cmd_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
4546
4708
  };
@@ -10641,6 +10803,7 @@ exports.InverseSparkline = InverseSparkline;
10641
10803
  exports.Label = Label3;
10642
10804
  exports.LineChart = LineChart;
10643
10805
  exports.LoadingTimeout = LoadingTimeout;
10806
+ exports.MarkdownEditor = MarkdownEditor;
10644
10807
  exports.MultiLineChart = MultiLineChart;
10645
10808
  exports.Popover = Popover;
10646
10809
  exports.PopoverContent = PopoverContent;