Compare commits

...

6 Commits

Author SHA1 Message Date
b15643bd80 Feat:VolcEngine Model type add IMAGE2TEXT (#10629)
### What problem does this PR solve?
issue:
[#9004](https://github.com/infiniflow/ragflow/issues/9004)
change:
VolcEngine Model type add IMAGE2TEXT

### Type of change

- [x] New Feature (non-breaking change which adds functionality)
2025-10-17 11:43:22 +08:00
f12290f04b Docs: minor (#10630)
### What problem does this PR solve?

### Type of change


- [x] Documentation Update
2025-10-17 11:41:19 +08:00
15838a6673 feat(storybook): Storybook with Calendar and Modal components #9869 (#10626)
### What problem does this PR solve?

feat(storybook): Storybook with Calendar and Modal components #9869

### Type of change

- [x] Bug Fix (non-breaking change which fixes an issue)
2025-10-17 09:58:52 +08:00
39ad9490ac Fix:display agents (#10620)
### What problem does this PR solve?

Clear agents display, remove empty value column.

### Type of change

- [x] Bug Fix (non-breaking change which fixes an issue)
2025-10-17 09:58:28 +08:00
387baf858f Feat: add MinerU parser (#10621)
### What problem does this PR solve?

Add MinerU parser. #3945, #8092.

Set `MINERU_EXECUTABLE` to the MinerU executable path, defaults to
`mineru`.

Set `MINERU_DELETE_OUTPUT=0` to preserve MinerU's output, default is 1,
which deletes temporary output.

Set `MINERU_OUTPUT_DIR` to choose the MinerU output directory (uses the
temporary directory if unset).

### Type of change

- [x] New Feature (non-breaking change which adds functionality)
2025-10-17 09:55:39 +08:00
2dba858c84 Doc: minor (#10627)
### What problem does this PR solve?


### Type of change


- [x] Documentation Update
2025-10-17 09:47:29 +08:00
13 changed files with 1484 additions and 28 deletions

View File

@ -166,8 +166,7 @@ class UserServiceMgr:
return [{
'title': r['title'],
'permission': r['permission'],
'canvas_type': r['canvas_type'],
'canvas_category': r['canvas_category']
'canvas_category': r['canvas_category'].split('-')[0]
} for r in res]

View File

@ -971,31 +971,9 @@
{
"name": "VolcEngine",
"logo": "",
"tags": "LLM, TEXT EMBEDDING",
"tags": "LLM, TEXT EMBEDDING, IMAGE2TEXT",
"status": "1",
"llm": [
{
"llm_name": "Doubao-pro-128k",
"tags": "LLM,CHAT,128k",
"max_tokens": 131072,
"model_type": "chat",
"is_tools": true
},
{
"llm_name": "Doubao-pro-32k",
"tags": "LLM,CHAT,32k",
"max_tokens": 32768,
"model_type": "chat",
"is_tools": true
},
{
"llm_name": "Doubao-pro-4k",
"tags": "LLM,CHAT,4k",
"max_tokens": 4096,
"model_type": "chat",
"is_tools": true
}
]
"llm": []
},
{
"name": "BaiChuan",

View File

@ -0,0 +1,344 @@
#
# Copyright 2025 The InfiniFlow Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
import json
import logging
import platform
import re
import subprocess
import sys
import tempfile
import threading
import time
from io import BytesIO
from os import PathLike
from pathlib import Path
from queue import Empty, Queue
from typing import Any, Callable, Optional
import numpy as np
import pdfplumber
from PIL import Image
from strenum import StrEnum
from deepdoc.parser.pdf_parser import RAGFlowPdfParser
LOCK_KEY_pdfplumber = "global_shared_lock_pdfplumber"
if LOCK_KEY_pdfplumber not in sys.modules:
sys.modules[LOCK_KEY_pdfplumber] = threading.Lock()
class MinerUContentType(StrEnum):
IMAGE = "image"
TABLE = "table"
TEXT = "text"
EQUATION = "equation"
class MinerUParser(RAGFlowPdfParser):
def __init__(self, mineru_path: str = "mineru"):
self.mineru_path = Path(mineru_path)
self.logger = logging.getLogger(self.__class__.__name__)
def check_installation(self) -> bool:
subprocess_kwargs = {
"capture_output": True,
"text": True,
"check": True,
"encoding": "utf-8",
"errors": "ignore",
}
if platform.system() == "Windows":
subprocess_kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0)
try:
result = subprocess.run([str(self.mineru_path), "--version"], **subprocess_kwargs)
version_info = result.stdout.strip()
if version_info:
logging.info(f"[MinerU] Detected version: {version_info}")
else:
logging.info("[MinerU] Detected MinerU, but version info is empty.")
return True
except subprocess.CalledProcessError as e:
logging.warning(f"[MinerU] Execution failed (exit code {e.returncode}).")
except FileNotFoundError:
logging.warning("[MinerU] MinerU not found. Please install it via: pip install -U 'mineru[core]'")
except Exception as e:
logging.error(f"[MinerU] Unexpected error during installation check: {e}")
return False
def _run_mineru(self, input_path: Path, output_dir: Path, method: str = "auto", lang: Optional[str] = None):
cmd = [str(self.mineru_path), "-p", str(input_path), "-o", str(output_dir), "-m", method]
if lang:
cmd.extend(["-l", lang])
self.logger.info(f"[MinerU] Running command: {' '.join(cmd)}")
subprocess_kwargs = {
"stdout": subprocess.PIPE,
"stderr": subprocess.PIPE,
"text": True,
"encoding": "utf-8",
"errors": "ignore",
"bufsize": 1,
}
if platform.system() == "Windows":
subprocess_kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0)
process = subprocess.Popen(cmd, **subprocess_kwargs)
stdout_queue, stderr_queue = Queue(), Queue()
def enqueue_output(pipe, queue, prefix):
for line in iter(pipe.readline, ""):
if line.strip():
queue.put((prefix, line.strip()))
pipe.close()
threading.Thread(target=enqueue_output, args=(process.stdout, stdout_queue, "STDOUT"), daemon=True).start()
threading.Thread(target=enqueue_output, args=(process.stderr, stderr_queue, "STDERR"), daemon=True).start()
while process.poll() is None:
for q in (stdout_queue, stderr_queue):
try:
while True:
prefix, line = q.get_nowait()
if prefix == "STDOUT":
self.logger.info(f"[MinerU] {line}")
else:
self.logger.warning(f"[MinerU] {line}")
except Empty:
pass
time.sleep(0.1)
return_code = process.wait()
if return_code != 0:
raise RuntimeError(f"[MinerU] Process failed with exit code {return_code}")
self.logger.info("[MinerU] Command completed successfully.")
def __images__(self, fnm, zoomin: int = 1, page_from=0, page_to=600, callback=None):
self.page_from = page_from
self.page_to = page_to
try:
with pdfplumber.open(fnm) if isinstance(fnm, (str, PathLike)) else pdfplumber.open(BytesIO(fnm)) as pdf:
self.pdf = pdf
self.page_images = [p.to_image(resolution=72 * zoomin, antialias=True).original for _, p in enumerate(self.pdf.pages[page_from:page_to])]
except Exception as e:
self.page_images = None
self.total_page = 0
logging.exception(e)
def _line_tag(self, bx):
pn = [bx["page_idx"] + 1]
positions = bx["bbox"]
x0, top, x1, bott = positions
if hasattr(self, "page_images") and self.page_images and len(self.page_images) > bx["page_idx"]:
page_width, page_height = self.page_images[bx["page_idx"]].size
x0 = (x0 / 1000.0) * page_width
x1 = (x1 / 1000.0) * page_width
top = (top / 1000.0) * page_height
bott = (bott / 1000.0) * page_height
return "@@{}\t{:.1f}\t{:.1f}\t{:.1f}\t{:.1f}##".format("-".join([str(p) for p in pn]), x0, x1, top, bott)
def crop(self, text, ZM=1, need_position=False):
imgs = []
poss = self.extract_positions(text)
if not poss:
if need_position:
return None, None
return
max_width = max(np.max([right - left for (_, left, right, _, _) in poss]), 6)
GAP = 6
pos = poss[0]
poss.insert(0, ([pos[0][0]], pos[1], pos[2], max(0, pos[3] - 120), max(pos[3] - GAP, 0)))
pos = poss[-1]
poss.append(([pos[0][-1]], pos[1], pos[2], min(self.page_images[pos[0][-1]].size[1], pos[4] + GAP), min(self.page_images[pos[0][-1]].size[1], pos[4] + 120)))
positions = []
for ii, (pns, left, right, top, bottom) in enumerate(poss):
right = left + max_width
if bottom <= top:
bottom = top + 2
for pn in pns[1:]:
bottom += self.page_images[pn - 1].size[1]
img0 = self.page_images[pns[0]]
x0, y0, x1, y1 = int(left), int(top), int(right), int(min(bottom, img0.size[1]))
crop0 = img0.crop((x0, y0, x1, y1))
imgs.append(crop0)
if 0 < ii < len(poss) - 1:
positions.append((pns[0] + self.page_from, x0, x1, y0, y1))
bottom -= img0.size[1]
for pn in pns[1:]:
page = self.page_images[pn]
x0, y0, x1, y1 = int(left), 0, int(right), int(min(bottom, page.size[1]))
cimgp = page.crop((x0, y0, x1, y1))
imgs.append(cimgp)
if 0 < ii < len(poss) - 1:
positions.append((pn + self.page_from, x0, x1, y0, y1))
bottom -= page.size[1]
if not imgs:
if need_position:
return None, None
return
height = 0
for img in imgs:
height += img.size[1] + GAP
height = int(height)
width = int(np.max([i.size[0] for i in imgs]))
pic = Image.new("RGB", (width, height), (245, 245, 245))
height = 0
for ii, img in enumerate(imgs):
if ii == 0 or ii + 1 == len(imgs):
img = img.convert("RGBA")
overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
overlay.putalpha(128)
img = Image.alpha_composite(img, overlay).convert("RGB")
pic.paste(img, (0, int(height)))
height += img.size[1] + GAP
if need_position:
return pic, positions
return pic
@staticmethod
def extract_positions(txt: str):
poss = []
for tag in re.findall(r"@@[0-9-]+\t[0-9.\t]+##", txt):
pn, left, right, top, bottom = tag.strip("#").strip("@").split("\t")
left, right, top, bottom = float(left), float(right), float(top), float(bottom)
poss.append(([int(p) - 1 for p in pn.split("-")], left, right, top, bottom))
return poss
def _read_output(self, output_dir: Path, file_stem: str, method: str = "auto") -> list[dict[str, Any]]:
subdir = output_dir / file_stem / method
json_file = subdir / f"{file_stem}_content_list.json"
if not json_file.exists():
raise FileNotFoundError(f"[MinerU] Missing output file: {json_file}")
with open(json_file, "r", encoding="utf-8") as f:
data = json.load(f)
for item in data:
for key in ("img_path", "table_img_path", "equation_img_path"):
if key in item and item[key]:
item[key] = str((subdir / item[key]).resolve())
return data
def _transfer_to_sections(self, outputs: list[dict[str, Any]]):
sections = []
for output in outputs:
match output["type"]:
case MinerUContentType.TEXT:
section = output["text"]
case MinerUContentType.TABLE:
section = output["table_body"] + "\n".join(output["table_caption"]) + "\n".join(output["table_footnote"])
case MinerUContentType.IMAGE:
section = "".join(output["image_caption"]) + "\n" + "".join(output["image_footnote"])
case MinerUContentType.EQUATION:
section = output["text"]
if section:
sections.append((section, self._line_tag(output)))
return sections
def _transfer_to_tables(self, outputs: list[dict[str, Any]]):
return []
def parse_pdf(
self,
filepath: str | PathLike[str],
binary: BytesIO | bytes,
callback: Optional[Callable] = None,
*,
output_dir: Optional[str] = None,
lang: Optional[str] = None,
method: str = "auto",
delete_output: bool = True,
) -> tuple:
import shutil
temp_pdf = None
created_tmp_dir = False
if binary:
temp_dir = Path(tempfile.mkdtemp(prefix="mineru_bin_pdf_"))
temp_pdf = temp_dir / Path(filepath).name
with open(temp_pdf, "wb") as f:
f.write(binary)
pdf = temp_pdf
self.logger.info(f"[MinerU] Received binary PDF -> {temp_pdf}")
if callback:
callback(0.15, f"[MinerU] Received binary PDF -> {temp_pdf}")
else:
pdf = Path(filepath)
if not pdf.exists():
if callback:
callback(-1, f"[MinerU] PDF not found: {pdf}")
raise FileNotFoundError(f"[MinerU] PDF not found: {pdf}")
if output_dir:
out_dir = Path(output_dir)
out_dir.mkdir(parents=True, exist_ok=True)
else:
out_dir = Path(tempfile.mkdtemp(prefix="mineru_pdf_"))
created_tmp_dir = True
self.logger.info(f"[MinerU] Output directory: {out_dir}")
if callback:
callback(0.15, f"[MinerU] Output directory: {out_dir}")
self.__images__(pdf, zoomin=1)
try:
self._run_mineru(pdf, out_dir, method=method, lang=lang)
outputs = self._read_output(out_dir, pdf.stem, method=method)
self.logger.info(f"[MinerU] Parsed {len(outputs)} blocks from PDF.")
if callback:
callback(0.75, f"[MinerU] Parsed {len(outputs)} blocks from PDF.")
return self._transfer_to_sections(outputs), self._transfer_to_tables(outputs)
finally:
if temp_pdf and temp_pdf.exists():
try:
temp_pdf.unlink()
temp_pdf.parent.rmdir()
except Exception:
pass
if delete_output and created_tmp_dir and out_dir.exists():
try:
shutil.rmtree(out_dir)
except Exception:
pass
if __name__ == "__main__":
parser = MinerUParser("mineru")
print("MinerU available:", parser.check_installation())
filepath = ""
with open(filepath, "rb") as file:
outputs = parser.parse_pdf(filepath=filepath, binary=file.read())
for output in outputs:
print(output)

View File

@ -0,0 +1,17 @@
---
sidebar_position: 30
slug: /parser_component
---
# Parser component
A component that sets the parsing rules for your dataset.
---
A **Parser** component defines how various file types should be parsed, including parsing methods for PDFs , fields to parse for Emails, and OCR methods for images.
## Scenario
A **Parser** component is auto-populated on the ingestion pipeline canvas and required in all ingestion pipeline workflows.

View File

@ -0,0 +1,29 @@
---
sidebar_position: 30
slug: /indexer_component
---
# Indexer component
A component that defines how chunks are indexed.
---
An **Indexer** component indexes chunks and configures their storage formats in the document engine.
## Scenario
An **Indexer** component is the mandatory ending component for all ingestion pipelines.
## Configurations
### Search method
This setting configures how chunks are stored in the document engine: as full-text, embeddings, or both.
### Filename embedding weight
This setting defines the filename's contribution to the final embedding, which is a weighted combination of both the chunk content and the filename. Essentially, a higher value gives the filename more influence in the final *composite* embedding.
- 0.1: Filename contributes 10% (chunk content 90%)
- 0.5 (maximum): Filename contributes 50% (chunk content 90%)

View File

@ -30,7 +30,7 @@ Released on October 15, 2025.
- Orchestratable ingestion pipeline: Supports customized data ingestion and cleansing workflows, enabling users to flexibly design their data flows or directly apply the official data flow templates on the canvas.
- GraphRAG & RAPTOR write process optimized: Replaces the automatic incremental build process with manual batch building, significantly reducing construction overhead.
- Long-context RAG: Automatically generates document-level table of contents (TOC) structures to mitigate context loss caused by inaccurate or excessive chunking, substantially improving retrieval quality. This feature is now available via a TOC extraction template.
- Long-context RAG: Automatically generates document-level table of contents (TOC) structures to mitigate context loss caused by inaccurate or excessive chunking, substantially improving retrieval quality. This feature is now available via a TOC extraction template. See [here](./guides/dataset/extract_table_of_contents.md).
- Video file parsing: Expands the system's multimodal data processing capabilities by supporting video file parsing.
- Admin CLI: Introduces a new command-line tool for system administration, allowing users to manage and monitor RAGFlow's service status via command line.

View File

@ -15,6 +15,7 @@
#
import logging
import os
import re
from functools import reduce
from io import BytesIO
@ -32,6 +33,7 @@ from api.db import LLMType
from api.db.services.llm_service import LLMBundle
from deepdoc.parser import DocxParser, ExcelParser, HtmlParser, JsonParser, MarkdownElementExtractor, MarkdownParser, PdfParser, TxtParser
from deepdoc.parser.figure_parser import VisionFigureParser, vision_figure_parser_figure_data_wrapper
from deepdoc.parser.mineru_parser import MinerUParser
from deepdoc.parser.pdf_parser import PlainParser, VisionParser
from rag.nlp import concat_img, find_codec, naive_merge, naive_merge_with_images, naive_merge_docx, rag_tokenizer, tokenize_chunks, tokenize_chunks_with_images, tokenize_table
@ -517,7 +519,22 @@ def chunk(filename, binary=None, from_page=0, to_page=100000,
res = tokenize_table(tables, doc, is_english)
callback(0.8, "Finish parsing.")
elif layout_recognizer == "MinerU":
mineru_executable = os.environ.get("MINERU_EXECUTABLE", "mineru")
pdf_parser = MinerUParser(mineru_path=mineru_executable)
if not pdf_parser.check_installation():
callback(-1, "MinerU not found.")
return res
sections, tables = pdf_parser.parse_pdf(
filepath=filename,
binary=binary,
callback=callback,
output_dir=os.environ.get("MINERU_OUTPUT_DIR", ""),
delete_output=bool(int(os.environ.get("MINERU_DELETE_OUTPUT", 1))),
)
parser_config["chunk_token_num"] = 0
callback(0.8, "Finish parsing.")
else:
if layout_recognizer == "Plain Text":
pdf_parser = PlainParser()

View File

@ -254,6 +254,17 @@ class StepFunCV(GptV4):
self.lang = lang
Base.__init__(self, **kwargs)
class VolcEngineCV(GptV4):
_FACTORY_NAME = "VolcEngine"
def __init__(self, key, model_name, lang="Chinese", base_url="https://ark.cn-beijing.volces.com/api/v3", **kwargs):
if not base_url:
base_url = "https://ark.cn-beijing.volces.com/api/v3"
ark_api_key = json.loads(key).get("ark_api_key", "")
self.client = OpenAI(api_key=ark_api_key, base_url=base_url)
self.model_name = json.loads(key).get("ep_id", "") + json.loads(key).get("endpoint_id", "")
self.lang = lang
Base.__init__(self, **kwargs)
class LmStudioCV(GptV4):
_FACTORY_NAME = "LM-Studio"

View File

@ -27,6 +27,21 @@ const config: StorybookConfig = {
},
],
},
{
test: /\.less$/,
use: [
'style-loader',
'css-loader',
{
loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: [require('tailwindcss'), require('autoprefixer')],
},
},
},
],
},
],
},
},

View File

@ -14,6 +14,12 @@ const sizeClasses = {
large: 'w-8 h-8',
};
const minSizeClasses = {
small: 'min-w-4 min-h-4',
default: 'min-w-6 min-h-6',
large: 'min-w-8 min-h-8',
};
export const Spin: React.FC<SpinProps> = ({
spinning = true,
size = 'default',
@ -32,7 +38,12 @@ export const Spin: React.FC<SpinProps> = ({
)}
>
{spinning && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-text-primary/30 ">
<div
className={cn(
'absolute inset-0 z-10 flex items-center justify-center bg-text-primary/30',
minSizeClasses[size],
)}
>
<div
className={cn(
'rounded-full border-muted-foreground border-2 border-t-transparent animate-spin',

View File

@ -81,6 +81,7 @@ const VolcEngineModal = ({
<Select placeholder={t('modelTypeMessage')}>
<Option value="chat">chat</Option>
<Option value="embedding">embedding</Option>
<Option value="image2text">image2text</Option>
</Select>
</Form.Item>
<Form.Item<FieldType>

View File

@ -0,0 +1,296 @@
import { Calendar } from '@/components/originui/calendar';
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import { useState } from 'react';
// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
const meta = {
title: 'Example/Calendar',
component: Calendar,
parameters: {
// Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
layout: 'centered',
docs: {
description: {
component: `
## Calendar Component
Calendar is a date picker component based on react-day-picker that allows users to select dates or date ranges. It provides a clean and customizable interface for date selection with support for various customization options.
### Import Path
\`\`\`typescript
import { Calendar } from '@/components/originui/calendar';
\`\`\`
### Basic Usage
\`\`\`tsx
import { Calendar } from '@/components/originui/calendar';
import { useState } from 'react';
function MyComponent() {
const [date, setDate] = useState<Date | undefined>(new Date());
return (
<Calendar
mode="single"
selected={date}
onSelect={setDate}
className="rounded-md border"
/>
);
}
\`\`\`
### Features
- Single date selection
- Date range selection
- Customizable styling with className prop
- Navigation between months
- Today highlighting
- Disabled dates support
- Customizable components
- Built with Tailwind CSS
`,
},
},
},
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
tags: ['autodocs'],
// More on argTypes: https://storybook.js.org/docs/api/argtypes
argTypes: {
mode: {
description: 'Selection mode - single date or range',
control: { type: 'radio' },
options: ['single', 'range'],
},
selected: {
description: 'Selected date or date range',
control: false,
},
onSelect: {
description: 'Callback function when date is selected',
control: false,
},
className: {
description: 'Additional CSS classes for styling',
control: { type: 'text' },
},
classNames: {
description: 'Custom class names for internal elements',
control: { type: 'object' },
},
showOutsideDays: {
description: 'Whether to show outside days',
control: { type: 'boolean' },
},
components: {
description: 'Custom components for calendar elements',
control: { type: 'object' },
},
},
} satisfies Meta<typeof Calendar>;
export default meta;
type Story = StoryObj<typeof meta>;
// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
export const Default: Story = {
args: {
showOutsideDays: true,
className: 'rounded-md border',
},
render: () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [date, setDate] = useState<Date | undefined>(new Date());
return (
<Calendar
mode="single"
selected={date}
onSelect={setDate}
showOutsideDays={true}
className="rounded-md border"
/>
);
},
parameters: {
docs: {
description: {
story: `
### Default Calendar
Shows the basic calendar with single date selection mode.
\`\`\`tsx
const [date, setDate] = useState<Date | undefined>(new Date());
<Calendar
mode="single"
selected={date}
onSelect={setDate}
className="rounded-md border"
showOutsideDays={true}
/>
\`\`\`
`,
},
},
},
};
export const RangeSelection: Story = {
args: {
showOutsideDays: true,
className: 'rounded-md border',
},
render: () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [range, setRange] = useState<{
from: Date | undefined;
to?: Date | undefined;
}>({
from: new Date(),
to: undefined,
});
return (
<Calendar
mode="range"
selected={range}
onSelect={(range) =>
setRange(range as { from: Date | undefined; to?: Date | undefined })
}
showOutsideDays={true}
className="rounded-md border"
/>
);
},
parameters: {
docs: {
description: {
story: `
### Range Selection Calendar
Shows the calendar with date range selection mode.
\`\`\`tsx
const [range, setRange] = useState<{ from: Date | undefined; to?: Date | undefined }>({
from: new Date(),
to: undefined,
});
<Calendar
mode="range"
selected={range}
onSelect={(date) => {
if (!range.from) {
setRange({ from: date });
} else if (!range.to && date && date > range.from) {
setRange({ from: range.from, to: date });
} else {
setRange({ from: date });
}
}}
className="rounded-md border"
showOutsideDays={true}
/>
\`\`\`
`,
},
},
},
};
export const WithoutOutsideDays: Story = {
args: {
showOutsideDays: false,
className: 'rounded-md border',
},
render: () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [date, setDate] = useState<Date | undefined>(new Date());
return (
<Calendar
mode="single"
selected={date}
onSelect={setDate}
showOutsideDays={false}
className="rounded-md border"
/>
);
},
parameters: {
docs: {
description: {
story: `
### Calendar without Outside Days
Shows the calendar without displaying days from previous/next months.
\`\`\`tsx
const [date, setDate] = useState<Date | undefined>(new Date());
<Calendar
mode="single"
selected={date}
onSelect={setDate}
className="rounded-md border"
showOutsideDays={false}
/>
\`\`\`
`,
},
},
},
};
export const CustomStyling: Story = {
args: {
showOutsideDays: true,
},
render: () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [date, setDate] = useState<Date | undefined>(new Date());
return (
<Calendar
mode="single"
selected={date}
onSelect={setDate}
showOutsideDays={true}
className="rounded-md border-2 border-primary bg-secondary"
classNames={{
caption_label: 'text-lg font-bold text-primary',
day_button: 'size-10 rounded-full hover:bg-primary/20',
}}
/>
);
},
parameters: {
docs: {
description: {
story: `
### Custom Styled Calendar
Shows the calendar with custom styling using className and classNames props.
\`\`\`tsx
const [date, setDate] = useState<Date | undefined>(new Date());
<Calendar
mode="single"
selected={date}
onSelect={setDate}
className="rounded-md border-2 border-primary bg-secondary"
classNames={{
caption_label: 'text-lg font-bold text-primary',
day_button: 'size-10 rounded-full hover:bg-primary/20',
}}
showOutsideDays={true}
/>
\`\`\`
`,
},
},
},
};

View File

@ -0,0 +1,738 @@
import { Button } from '@/components/ui/button';
import { Modal } from '@/components/ui/modal/modal';
import type { Meta, StoryObj } from '@storybook/react-webpack5';
import { useState } from 'react';
// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
const meta = {
title: 'Example/Modal',
component: Modal,
parameters: {
// Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
layout: 'centered',
docs: {
description: {
component: `
## Modal Component
The Modal component is a dialog overlay that can be used to present content in a modal window. It provides a flexible way to display information, forms, or any other content on top of the main page content.
### Import Path
\`\`\`typescript
import { Modal } from '@/components/ui/modal/modal';
\`\`\`
### Basic Usage
\`\`\`tsx
import { Modal } from '@/components/ui/modal/modal';
import { useState } from 'react';
function MyComponent() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Open Modal</button>
<Modal
open={open}
onOpenChange={setOpen}
title="Modal Title"
>
<p>Modal content goes here</p>
</Modal>
</>
);
}
\`\`\`
### Features
- Multiple sizes: small, default, and large
- Customizable header with title and close button
- Customizable footer with default OK/Cancel buttons
- Support for controlled and uncontrolled usage
- Loading state for confirmation button
- Keyboard navigation support (ESC to close)
- Click outside to close functionality
- Full screen mode option
- Built with Radix UI primitives for accessibility
- Customizable styling with className props
`,
},
},
},
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
tags: ['autodocs'],
// More on argTypes: https://storybook.js.org/docs/api/argtypes
argTypes: {
open: {
description: 'Whether the modal is open or not',
control: { type: 'boolean' },
},
onOpenChange: {
description:
'Callback function that is called when the open state changes',
control: false,
},
title: {
description: 'Title of the modal',
control: { type: 'text' },
},
titleClassName: {
description: 'Additional CSS classes for the title container',
control: { type: 'text' },
},
children: {
description: 'Content to be displayed inside the modal',
control: false,
},
footer: {
description:
'Custom footer content. If not provided, default buttons will be shown',
control: { type: 'text' },
},
footerClassName: {
description: 'Additional CSS classes for the footer container',
control: { type: 'text' },
},
showfooter: {
description: 'Whether to show the footer or not',
control: { type: 'boolean' },
},
className: {
description: 'Additional CSS classes for the modal container',
control: { type: 'text' },
},
size: {
description: 'Size of the modal',
control: { type: 'select' },
options: ['small', 'default', 'large'],
},
closable: {
description: 'Whether to show the close button in the header',
control: { type: 'boolean' },
},
closeIcon: {
description: 'Custom close icon',
control: false,
},
maskClosable: {
description: 'Whether to close the modal when clicking on the mask',
control: { type: 'boolean' },
},
destroyOnClose: {
description: 'Whether to unmount the modal content when closed',
control: { type: 'boolean' },
},
full: {
description: 'Whether the modal should take the full screen',
control: { type: 'boolean' },
},
confirmLoading: {
description: 'Whether the confirm button should show a loading state',
control: { type: 'boolean' },
},
cancelText: {
description: 'Text for the cancel button',
control: { type: 'text' },
},
okText: {
description: 'Text for the OK button',
control: { type: 'text' },
},
onOk: {
description: 'Callback function for the OK button',
control: false,
},
onCancel: {
description: 'Callback function for the Cancel button',
control: false,
},
},
} satisfies Meta<typeof Modal>;
export default meta;
type Story = StoryObj<typeof meta>;
// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
export const Default: Story = {
args: {
open: false,
title: 'Default Modal',
children: (
<div>
<h3 className="text-lg font-medium mb-2">Modal Content</h3>
<p className="text-muted-foreground">
This is the default modal with standard size and functionality.
</p>
</div>
),
},
render: (args) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [open, setOpen] = useState(false);
return (
<div>
<Button onClick={() => setOpen(true)}>Open Default Modal</Button>
<Modal open={open} onOpenChange={setOpen} title={args.title}>
{args.children}
</Modal>
</div>
);
},
parameters: {
docs: {
description: {
story: `
### Default Modal
Shows the basic modal with default size and standard header/footer.
\`\`\`tsx
const [open, setOpen] = useState(false);
<Button onClick={() => setOpen(true)}>Open Default Modal</Button>
<Modal
open={open}
onOpenChange={setOpen}
title="Default Modal"
>
<div>
<h3 className="text-lg font-medium mb-2">Modal Content</h3>
<p className="text-muted-foreground">
This is the default modal with standard size and functionality.
</p>
</div>
</Modal>
\`\`\`
`,
},
},
},
};
export const Small: Story = {
args: {
open: false,
title: 'Small Modal',
size: 'small',
children: (
<div>
<h3 className="text-lg font-medium mb-2">Small Modal</h3>
<p className="text-muted-foreground">
This is a small modal, suitable for simple confirmations or short
messages.
</p>
</div>
),
},
render: (args) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [open, setOpen] = useState(false);
return (
<div>
<Button onClick={() => setOpen(true)}>Open Small Modal</Button>
<Modal
open={open}
onOpenChange={setOpen}
title={args.title}
size={args.size}
>
{args.children}
</Modal>
</div>
);
},
parameters: {
docs: {
description: {
story: `
### Small Modal
Shows a small-sized modal, ideal for confirmations or brief messages.
\`\`\`tsx
const [open, setOpen] = useState(false);
<Button onClick={() => setOpen(true)}>Open Small Modal</Button>
<Modal
open={open}
onOpenChange={setOpen}
title="Small Modal"
size="small"
>
<div>
<h3 className="text-lg font-medium mb-2">Small Modal</h3>
<p className="text-muted-foreground">
This is a small modal, suitable for simple confirmations or short messages.
</p>
</div>
</Modal>
\`\`\`
`,
},
},
},
};
export const Large: Story = {
args: {
open: false,
title: 'Large Modal',
size: 'large',
children: (
<div>
<h3 className="text-lg font-medium mb-2">Large Modal</h3>
<p className="text-muted-foreground mb-4">
This is a large modal with more content. It can accommodate forms,
tables, or other complex content.
</p>
<div className="bg-muted p-4 rounded-md">
<p>Additional content area</p>
<p className="mt-2">You can put any content here</p>
</div>
</div>
),
},
render: (args) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [open, setOpen] = useState(false);
return (
<div>
<Button onClick={() => setOpen(true)}>Open Large Modal</Button>
<Modal
open={open}
onOpenChange={setOpen}
title={args.title}
size={args.size}
>
{args.children}
</Modal>
</div>
);
},
parameters: {
docs: {
description: {
story: `
### Large Modal
Shows a large-sized modal, suitable for complex content like forms or data tables.
\`\`\`tsx
const [open, setOpen] = useState(false);
<Button onClick={() => setOpen(true)}>Open Large Modal</Button>
<Modal
open={open}
onOpenChange={setOpen}
title="Large Modal"
size="large"
>
<div>
<h3 className="text-lg font-medium mb-2">Large Modal</h3>
<p className="text-muted-foreground mb-4">
This is a large modal with more content. It can accommodate forms, tables, or other complex content.
</p>
<div className="bg-muted p-4 rounded-md">
<p>Additional content area</p>
<p className="mt-2">You can put any content here</p>
</div>
</div>
</Modal>
\`\`\`
`,
},
},
},
};
export const WithCustomFooter: Story = {
args: {
open: false,
title: 'Custom Footer',
children: (
<div>
<h3 className="text-lg font-medium mb-2">Modal with Custom Footer</h3>
<p className="text-muted-foreground">
This modal has a custom footer with multiple buttons.
</p>
</div>
),
},
render: (args) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [open, setOpen] = useState(false);
return (
<div>
<Button onClick={() => setOpen(true)}>
Open Modal with Custom Footer
</Button>
<Modal
open={open}
onOpenChange={setOpen}
title={args.title}
footer={
<div className="flex justify-between w-full">
<Button variant="outline">Secondary Action</Button>
<div className="space-x-2">
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button>Primary Action</Button>
</div>
</div>
}
>
{args.children}
</Modal>
</div>
);
},
parameters: {
docs: {
description: {
story: `
### Custom Footer
Shows a modal with a custom footer. You can provide your own footer content instead of using the default OK/Cancel buttons.
\`\`\`tsx
const [open, setOpen] = useState(false);
<Button onClick={() => setOpen(true)}>Open Modal with Custom Footer</Button>
<Modal
open={open}
onOpenChange={setOpen}
title="Custom Footer"
footer={
<div className="flex justify-between w-full">
<Button variant="outline">Secondary Action</Button>
<div className="space-x-2">
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button>Primary Action</Button>
</div>
</div>
}
>
<div>
<h3 className="text-lg font-medium mb-2">Modal with Custom Footer</h3>
<p className="text-muted-foreground">
This modal has a custom footer with multiple buttons.
</p>
</div>
</Modal>
\`\`\`
`,
},
},
},
};
export const WithoutFooter: Story = {
args: {
open: false,
title: 'No Footer',
children: (
<div>
<h3 className="text-lg font-medium mb-2">Modal without Footer</h3>
<p className="text-muted-foreground">
This modal has no footer. The content area extends to the bottom of
the modal.
</p>
</div>
),
},
render: (args) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [open, setOpen] = useState(false);
return (
<div>
<Button onClick={() => setOpen(true)}>Open Modal without Footer</Button>
<Modal
open={open}
onOpenChange={setOpen}
title={args.title}
showfooter={false}
>
{args.children}
</Modal>
</div>
);
},
parameters: {
docs: {
description: {
story: `
### Without Footer
Shows a modal without a footer. Useful when you want to include action buttons within the content area or don't need any footer actions.
\`\`\`tsx
const [open, setOpen] = useState(false);
<Button onClick={() => setOpen(true)}>Open Modal without Footer</Button>
<Modal
open={open}
onOpenChange={setOpen}
title="No Footer"
showfooter={false}
>
<div>
<h3 className="text-lg font-medium mb-2">Modal without Footer</h3>
<p className="text-muted-foreground">
This modal has no footer. The content area extends to the bottom of the modal.
</p>
</div>
</Modal>
\`\`\`
`,
},
},
},
};
export const FullScreen: Story = {
args: {
open: false,
title: 'Full Screen Modal',
children: (
<div className="h-96 flex flex-col">
<h3 className="text-lg font-medium mb-2">Full Screen Modal</h3>
<p className="text-muted-foreground mb-4">
This modal takes up the full screen. Useful for complex workflows or
when you need maximum space.
</p>
<div className="flex-grow bg-muted rounded-md p-4">
<p>Content area that can expand to fill available space</p>
</div>
</div>
),
},
render: (args) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [open, setOpen] = useState(false);
return (
<div>
<Button onClick={() => setOpen(true)}>Open Full Screen Modal</Button>
<Modal
open={open}
onOpenChange={setOpen}
title={args.title}
full={true}
>
{args.children}
</Modal>
</div>
);
},
parameters: {
docs: {
description: {
story: `
### Full Screen Modal
Shows a full screen modal that takes up the entire viewport. Useful for complex workflows or when maximum space is needed.
\`\`\`tsx
const [open, setOpen] = useState(false);
<Button onClick={() => setOpen(true)}>Open Full Screen Modal</Button>
<Modal
open={open}
onOpenChange={setOpen}
title="Full Screen Modal"
full={true}
>
<div className="h-96 flex flex-col">
<h3 className="text-lg font-medium mb-2">Full Screen Modal</h3>
<p className="text-muted-foreground mb-4">
This modal takes up the full screen. Useful for complex workflows or when you need maximum space.
</p>
<div className="flex-grow bg-muted rounded-md p-4">
<p>Content area that can expand to fill available space</p>
</div>
</div>
</Modal>
\`\`\`
`,
},
},
},
};
export const LoadingState: Story = {
args: {
open: false,
title: 'Loading State',
children: (
<div>
<h3 className="text-lg font-medium mb-2">Modal with Loading State</h3>
<p className="text-muted-foreground">
The OK button shows a loading spinner when confirmLoading is true.
</p>
</div>
),
},
render: (args) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [open, setOpen] = useState(false);
// eslint-disable-next-line react-hooks/rules-of-hooks
const [loading, setLoading] = useState(false);
const handleOk = () => {
setLoading(true);
setTimeout(() => {
setLoading(false);
setOpen(false);
}, 2000);
};
return (
<div>
<Button onClick={() => setOpen(true)}>Open Loading State Modal</Button>
<Modal
open={open}
onOpenChange={setOpen}
title={args.title}
confirmLoading={loading}
onOk={handleOk}
>
{args.children}
</Modal>
</div>
);
},
parameters: {
docs: {
description: {
story: `
### Loading State
Shows a modal with the confirm button in a loading state. This is useful when performing async operations after clicking OK.
\`\`\`tsx
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const handleOk = () => {
setLoading(true);
setTimeout(() => {
setLoading(false);
setOpen(false);
}, 2000);
};
<Button onClick={() => setOpen(true)}>Open Loading State Modal</Button>
<Modal
open={open}
onOpenChange={setOpen}
title="Loading State"
confirmLoading={loading}
onOk={handleOk}
>
<div>
<h3 className="text-lg font-medium mb-2">Modal with Loading State</h3>
<p className="text-muted-foreground">
The OK button shows a loading spinner when confirmLoading is true.
</p>
</div>
</Modal>
\`\`\`
`,
},
},
},
};
// Interactive example showing how to use the modal in a real component
export const Interactive: Story = {
args: {
open: false,
title: 'Interactive Modal',
children: (
<div>
<h3 className="text-lg font-medium mb-2">Interactive Modal</h3>
<p className="text-muted-foreground">
Click OK to see the loading state, or click Cancel/Close to close the
modal.
</p>
</div>
),
},
render: (args) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [open, setOpen] = useState(false);
return (
<div>
<Button onClick={() => setOpen(true)}>Open Interactive Modal</Button>
<Modal
open={open}
onOpenChange={setOpen}
title={args.title}
onOk={() => {
// Simulate API call
setTimeout(() => {
setOpen(false);
}, 1000);
}}
>
{args.children}
</Modal>
</div>
);
},
parameters: {
docs: {
description: {
story: `
### Interactive Example
This is a fully interactive example showing how to use the modal in a real component with state management.
\`\`\`tsx
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Modal } from '@/components/ui/modal/modal';
function InteractiveModal() {
const [open, setOpen] = useState(false);
return (
<div>
<Button onClick={() => setOpen(true)}>Open Interactive Modal</Button>
<Modal
open={open}
onOpenChange={setOpen}
title="Interactive Modal"
onOk={() => {
// Simulate API call
setTimeout(() => {
setOpen(false);
}, 1000);
}}
>
<div>
<h3 className="text-lg font-medium mb-2">Interactive Modal</h3>
<p className="text-muted-foreground">
Click OK to see the loading state, or click Cancel/Close to close the modal.
</p>
</div>
</Modal>
</div>
);
}
\`\`\`
`,
},
},
},
};