@loword/loword-design-system 0.7.2 → 0.7.4
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/README.md +21 -2
- package/dist/components/LDEditor/EditorBase.d.ts +5 -2
- package/dist/components/LDEditor/EditorBase.d.ts.map +1 -1
- package/dist/components/LDEditor/EditorBase.js +21 -13
- package/dist/components/LDEditor/LDEditor.d.ts +30 -1
- package/dist/components/LDEditor/LDEditor.d.ts.map +1 -1
- package/dist/components/LDEditor/LDEditor.js +184 -49
- package/dist/components/LDEditor/LDViewer.d.ts +7 -1
- package/dist/components/LDEditor/LDViewer.d.ts.map +1 -1
- package/dist/components/LDEditor/LDViewer.js +2 -2
- package/dist/components/LDEditor/extensions.d.ts +5 -0
- package/dist/components/LDEditor/extensions.d.ts.map +1 -1
- package/dist/components/LDEditor/extensions.js +109 -5
- package/dist/components/LDFileUpload/LDFileUpload.d.ts.map +1 -1
- package/dist/components/LDFileUpload/LDFileUpload.js +1 -1
- package/dist/components/LDPagination/PaginationBase.d.ts.map +1 -1
- package/dist/components/LDPagination/PaginationBase.js +7 -3
- package/dist/components/LDTeamHistory/TeamHistoryBase.d.ts.map +1 -1
- package/dist/components/LDTeamHistory/TeamHistoryBase.js +1 -1
- package/dist/index.css +75 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/utils/cn.d.ts +33 -0
- package/dist/utils/cn.d.ts.map +1 -1
- package/dist/utils/cn.js +55 -23
- package/package.json +2 -3
- package/src/styles/components/editor.css +94 -2
package/README.md
CHANGED
|
@@ -97,7 +97,26 @@ import LDButton from '@loword/loword-design-system/components/LDButton/LDButton'
|
|
|
97
97
|
</Show>
|
|
98
98
|
```
|
|
99
99
|
|
|
100
|
-
|
|
100
|
+
`createCn` — LDS 토큰(커스텀 display·font-weight·spacing·icon-size·숫자 radius)을 아는 `clsx` + `tailwind-merge` 병합 함수 `cn`을 만듭니다. 등록할 토큰이 없으면 `createCn()`, 있으면 `extension`을 넘겨 LDS 설정 뒤에 얹습니다(`extend`는 LDS 값에 추가, `override`는 LDS가 등록한 것까지 교체). 커스텀 class group id는 타입 인자로 넘깁니다.
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
// 소비처 lib/cn.ts — 모듈 스코프에서 한 번만 만든다
|
|
104
|
+
import { createCn } from '@loword/loword-design-system';
|
|
105
|
+
|
|
106
|
+
export const cn = createCn<'elevation'>({
|
|
107
|
+
extend: {
|
|
108
|
+
// 자체 타이포 토큰 text-caption 을 font-size 로 인식시킨다(미등록 시 text-color 로 오인돼 text-red-500 을 지움)
|
|
109
|
+
theme: { text: ['caption', 'body1'] },
|
|
110
|
+
// 자체 유틸 elevation-* 를 새 그룹으로 등록하고 shadow-* 와 상충시킨다
|
|
111
|
+
classGroups: { elevation: [{ elevation: ['1', '2', '3'] }] },
|
|
112
|
+
conflictingClassGroups: { elevation: ['shadow'] },
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`createCn`은 호출마다 독립된 tailwind-merge 인스턴스(설정·캐시)를 만들므로 모듈 스코프에서 한 번만 만들어 재사용합니다.
|
|
118
|
+
|
|
119
|
+
> barrel의 값 export는 `Show` · `openModal`(`LDModal`의 하위호환 별칭) · `TextareaContent` · `createCn` 넷 말고는 전부 `LD` 접두사입니다. 타입 export에는 예외가 조금 더 있습니다.
|
|
101
120
|
|
|
102
121
|
## 빠른 시작 (개발)
|
|
103
122
|
|
|
@@ -108,7 +127,7 @@ pnpm storybook # Storybook 개발 서버 (port 6006)
|
|
|
108
127
|
pnpm build # 프로덕션 빌드
|
|
109
128
|
pnpm check-types # 타입 체크
|
|
110
129
|
pnpm lint # ESLint (--fix 포함, src + .storybook)
|
|
111
|
-
pnpm prettier # Prettier
|
|
130
|
+
pnpm prettier # Prettier (--write, 저장소 전체 — CI 포매팅 검사와 같은 범위)
|
|
112
131
|
pnpm test # vitest (utils 유닛 + 스토리 브라우저 실행 + a11y)
|
|
113
132
|
pnpm test:coverage # 커버리지 (utils / components 분리 측정)
|
|
114
133
|
pnpm changeset # .changes/{브랜치명}.md 생성
|
|
@@ -9,6 +9,8 @@ interface RootProps {
|
|
|
9
9
|
content?: string;
|
|
10
10
|
onChange?: (html: string) => void;
|
|
11
11
|
className?: string;
|
|
12
|
+
/** prose 루트(편집 영역)에 인라인 font-size로 적용한다. 지정하지 않으면 prose-lg 기본(18px). */
|
|
13
|
+
defaultFontSize?: string;
|
|
12
14
|
children?: ReactNode;
|
|
13
15
|
}
|
|
14
16
|
interface ContentProps {
|
|
@@ -39,15 +41,16 @@ interface LDViewerProps {
|
|
|
39
41
|
extensions: Extensions;
|
|
40
42
|
content: string;
|
|
41
43
|
className?: string;
|
|
44
|
+
defaultFontSize?: string;
|
|
42
45
|
}
|
|
43
46
|
export declare const EditorBase: {
|
|
44
|
-
Root: ({ extensions, content, onChange, className, children, }: RootProps) => import("react/jsx-runtime").JSX.Element;
|
|
47
|
+
Root: ({ extensions, content, onChange, className, defaultFontSize, children, }: RootProps) => import("react/jsx-runtime").JSX.Element;
|
|
45
48
|
Content: ({ className }: ContentProps) => import("react/jsx-runtime").JSX.Element;
|
|
46
49
|
Toolbar: (({ className, children }: ToolbarProps) => import("react/jsx-runtime").JSX.Element | null) & {
|
|
47
50
|
LDButton: ({ disabled, onClick, className, children, "aria-label": ariaLabel, }: ToolbarButtonProps) => import("react/jsx-runtime").JSX.Element;
|
|
48
51
|
Dropdown: ({ trigger, className, children, }: ToolbarDropdownProps) => import("react/jsx-runtime").JSX.Element;
|
|
49
52
|
};
|
|
50
|
-
LDViewer: ({ extensions, content, className }: LDViewerProps) => import("react/jsx-runtime").JSX.Element | null;
|
|
53
|
+
LDViewer: ({ extensions, content, className, defaultFontSize, }: LDViewerProps) => import("react/jsx-runtime").JSX.Element | null;
|
|
51
54
|
};
|
|
52
55
|
export default EditorBase;
|
|
53
56
|
//# sourceMappingURL=EditorBase.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"EditorBase.d.ts","sourceRoot":"","sources":["../../../src/components/LDEditor/EditorBase.tsx"],"names":[],"mappings":"AAEA,OAAO,EACL,KAAK,MAAM,EAEX,KAAK,UAAU,EAEhB,MAAM,eAAe,CAAC;AACvB,OAAO,EAEL,KAAK,SAAS,
|
|
1
|
+
{"version":3,"file":"EditorBase.d.ts","sourceRoot":"","sources":["../../../src/components/LDEditor/EditorBase.tsx"],"names":[],"mappings":"AAEA,OAAO,EACL,KAAK,MAAM,EAEX,KAAK,UAAU,EAEhB,MAAM,eAAe,CAAC;AACvB,OAAO,EAEL,KAAK,SAAS,EAOf,MAAM,OAAO,CAAC;AAKf,UAAU,iBAAiB;IACzB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAID,eAAO,MAAM,gBAAgB,yBAS5B,CAAC;AAGF,UAAU,SAAS;IACjB,UAAU,EAAE,UAAU,CAAC;IACvB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,wEAAwE;IACxE,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,EAAE,SAAS,CAAC;CACtB;AA4DD,UAAU,YAAY;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAQD,UAAU,YAAY;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,SAAS,CAAC;CACtB;AAeD,UAAU,kBAAkB;IAC1B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AA0BD,UAAU,oBAAoB;IAC5B,OAAO,EAAE,CAAC,IAAI,EAAE;QAAE,IAAI,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,MAAM,IAAI,CAAA;KAAE,KAAK,SAAS,CAAC;IACpE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,SAAS,GAAG,CAAC,CAAC,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,IAAI,CAAA;KAAE,KAAK,SAAS,CAAC,CAAC;CACrE;AA4ED,UAAU,aAAa;IACrB,UAAU,EAAE,UAAU,CAAC;IACvB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AA2BD,eAAO,MAAM,UAAU;qFA7MpB,SAAS;6BAqCoB,YAAY;wCAWE,YAAY;yFA2BvD,kBAAkB;sDA4BlB,oBAAoB;;qEAkFpB,aAAa;CAyBf,CAAC;AAEF,eAAe,UAAU,CAAC"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { EditorContent, useEditor, } from '@tiptap/react';
|
|
4
|
-
import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState, } from 'react';
|
|
4
|
+
import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
|
|
5
5
|
import cn from '../../utils/cn.js';
|
|
6
6
|
const EditorContext = createContext(null);
|
|
7
7
|
export const useEditorContext = () => {
|
|
@@ -11,22 +11,30 @@ export const useEditorContext = () => {
|
|
|
11
11
|
}
|
|
12
12
|
return editorContext;
|
|
13
13
|
};
|
|
14
|
-
const
|
|
14
|
+
const getEditorAttributes = ({ className, defaultFontSize, }) => ({
|
|
15
|
+
// role이 없는 div에는 aria-label이 허용되지 않아 스크린리더가 이름을 무시한다.
|
|
16
|
+
role: 'textbox',
|
|
17
|
+
'aria-multiline': 'true',
|
|
18
|
+
'aria-label': '본문 편집기',
|
|
19
|
+
...(defaultFontSize && { style: `font-size: ${defaultFontSize}` }),
|
|
20
|
+
class: cn(
|
|
21
|
+
// ld-editor-render: 렌더(LDViewer·정적 렌더)와 같은 규칙(링크 색 등)을 공유한다.
|
|
22
|
+
// 높이는 처음 값만 정하고(소비처 h-*가 이긴다) 사용자가 오른쪽 아래 모서리를 끌어 바꾼다. min-h는 너무 줄어들지 않게 하는 하한.
|
|
23
|
+
'ld-editor-render prose prose-lg max-w-none rounded-b-md border-x border-b border-neutral200 px-4 py-3 outline-none focus:outline-none', 'h-204.5 min-h-40 resize-y overflow-y-auto [scrollbar-gutter:stable] mobile:h-[320px]', className),
|
|
24
|
+
});
|
|
25
|
+
const Root = ({ extensions, content = '', onChange, className, defaultFontSize, children, }) => {
|
|
26
|
+
const attributes = useMemo(() => getEditorAttributes({ className, defaultFontSize }), [className, defaultFontSize]);
|
|
15
27
|
const editor = useEditor({
|
|
16
28
|
extensions,
|
|
17
29
|
content,
|
|
18
30
|
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
|
|
19
31
|
immediatelyRender: false,
|
|
20
|
-
editorProps: {
|
|
21
|
-
attributes: {
|
|
22
|
-
// role이 없는 div에는 aria-label이 허용되지 않아 스크린리더가 이름을 무시한다.
|
|
23
|
-
role: 'textbox',
|
|
24
|
-
'aria-multiline': 'true',
|
|
25
|
-
'aria-label': '본문 편집기',
|
|
26
|
-
class: cn('prose prose-lg h-204.5 max-w-none overflow-y-auto rounded-b-md border-x border-b border-neutral200 px-4 py-3 outline-none focus:outline-none mobile:h-[320px]', className),
|
|
27
|
-
},
|
|
28
|
-
},
|
|
32
|
+
editorProps: { attributes },
|
|
29
33
|
});
|
|
34
|
+
// 에디터는 한 번만 만들어지므로, 이후 바뀐 defaultFontSize·className을 반영한다.
|
|
35
|
+
useLayoutEffect(() => {
|
|
36
|
+
editor?.setOptions({ editorProps: { attributes } });
|
|
37
|
+
}, [editor, attributes]);
|
|
30
38
|
useEffect(() => {
|
|
31
39
|
if (!editor)
|
|
32
40
|
return;
|
|
@@ -98,7 +106,7 @@ const Toolbar = Object.assign(ToolbarRoot, {
|
|
|
98
106
|
LDButton: ToolbarButton,
|
|
99
107
|
Dropdown: ToolbarDropdown,
|
|
100
108
|
});
|
|
101
|
-
const LDViewer = ({ extensions, content, className }) => {
|
|
109
|
+
const LDViewer = ({ extensions, content, className, defaultFontSize, }) => {
|
|
102
110
|
const editor = useEditor({
|
|
103
111
|
extensions,
|
|
104
112
|
content: content || '',
|
|
@@ -109,7 +117,7 @@ const LDViewer = ({ extensions, content, className }) => {
|
|
|
109
117
|
});
|
|
110
118
|
if (!editor)
|
|
111
119
|
return null;
|
|
112
|
-
return _jsx(EditorContent, { editor: editor, className: className });
|
|
120
|
+
return (_jsx(EditorContent, { editor: editor, className: className, style: defaultFontSize ? { fontSize: defaultFontSize } : undefined }));
|
|
113
121
|
};
|
|
114
122
|
export const EditorBase = {
|
|
115
123
|
Root,
|
|
@@ -1,8 +1,37 @@
|
|
|
1
1
|
import { type TMimeType } from '../../constants/file.js';
|
|
2
|
+
declare const FONT_SIZES: readonly ["12px", "14px", "16px", "18px", "20px", "24px", "28px", "32px", "36px", "40px"];
|
|
3
|
+
/** 툴바 글자 크기 목록의 값 */
|
|
4
|
+
export type LDEditorFontSize = (typeof FONT_SIZES)[number];
|
|
2
5
|
export interface LDEditorProps {
|
|
3
6
|
content: string;
|
|
4
7
|
onChange?: (html: string) => void;
|
|
8
|
+
/**
|
|
9
|
+
* 편집 영역(`.ProseMirror`)에 붙는다. `h-*`는 편집 영역의 **처음 높이**다(기본 `h-204.5`, 모바일 320px).
|
|
10
|
+
* 사용자는 textarea처럼 오른쪽 아래 모서리를 끌어 세로 높이를 바꾸고, 내용이 넘치면 안쪽으로 스크롤된다.
|
|
11
|
+
* 너무 줄어들지 않게 최소 높이(`min-h-40`)가 있으며 `min-h-*`로 바꿀 수 있다.
|
|
12
|
+
*/
|
|
5
13
|
className?: string;
|
|
14
|
+
/**
|
|
15
|
+
* 툴바를 페이지 스크롤에 따라 위에 붙인다(`sticky`, 내용이 비치지 않게 흰 배경). 기본 `false`(툴바가 함께 스크롤됨).
|
|
16
|
+
* 붙는 위치와 쌓임 순서는 `toolbarClassName`의 `top-*`·`z-*`로 정한다(기본 `top-0`·`z-20`).
|
|
17
|
+
*/
|
|
18
|
+
stickyToolbar?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* 툴바에 붙는다. `stickyToolbar`일 때 소비처 GNB 높이만큼 `top-*`을 주면 GNB 아래에 붙고,
|
|
21
|
+
* GNB가 툴바(`z-20`)보다 아래 쌓이면 `z-*`로 순서를 맞춘다.
|
|
22
|
+
*/
|
|
23
|
+
toolbarClassName?: string;
|
|
24
|
+
/**
|
|
25
|
+
* 본문 기본 글자 크기(툴바 글자 크기 목록 중 하나). 기본 `'18px'`(prose-lg 기본과 같음).
|
|
26
|
+
* 편집 영역 prose 루트에 인라인 `font-size`로 적용하며 제목·여백은 em 비율로 같이 커지고 작아진다.
|
|
27
|
+
* 글자 크기 mark가 없는 글의 툴바 표시도 이 값을 보여 준다.
|
|
28
|
+
* 정적 렌더(`div.prose.prose-lg > div.ProseMirror`)는 **prose 루트(바깥 div)에 이 클래스(`ld-editor-render`)와 같은 `font-size`를 준다**.
|
|
29
|
+
* 정적 렌더 내용 폭 = 에디터 바깥 폭 − 34px(테두리 1 + 여백 16, 양쪽). 렌더 루트 바깥 폭을 그만큼 좁히면(또는 같은 inline 좌우 여백을 주면) 줄바꿈이 같다.
|
|
30
|
+
* 편집 영역은 스크롤바 자리를 항상 확보(`scrollbar-gutter: stable`)하고, `ld-editor-render`도 같은 거터를 확보한다(높이는 auto라 스크롤은 생기지 않음).
|
|
31
|
+
* `ld-editor-render`는 빈 문단(`<p></p>`)을 한 줄 높이로, 연속 공백·U+2028을 보존하고, 링크 글자·밑줄 색이 주변 글자 색을 따르게 해 편집 화면과 같게 그린다(붙이지 않으면 기존 렌더 그대로).
|
|
32
|
+
* `LDViewer`는 같은 이름의 prop을 받는다.
|
|
33
|
+
*/
|
|
34
|
+
defaultFontSize?: LDEditorFontSize;
|
|
6
35
|
/** 툴바 이미지 업로드와 이미지 붙여넣기에서 호출된다. 반환한 URL이 img의 src가 된다. */
|
|
7
36
|
handleImageUpload?: (fileData: {
|
|
8
37
|
filebase64: string;
|
|
@@ -24,6 +53,6 @@ export interface LDEditorProps {
|
|
|
24
53
|
height: number;
|
|
25
54
|
};
|
|
26
55
|
}
|
|
27
|
-
declare const LDEditor: ({ content, onChange, className, handleImageUpload, allowedImageHosts, allowedExtensions, maxSize, maxImageSize, }: LDEditorProps) => import("react/jsx-runtime").JSX.Element;
|
|
56
|
+
declare const LDEditor: ({ content, onChange, className, stickyToolbar, toolbarClassName, defaultFontSize, handleImageUpload, allowedImageHosts, allowedExtensions, maxSize, maxImageSize, }: LDEditorProps) => import("react/jsx-runtime").JSX.Element;
|
|
28
57
|
export default LDEditor;
|
|
29
58
|
//# sourceMappingURL=LDEditor.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"LDEditor.d.ts","sourceRoot":"","sources":["../../../src/components/LDEditor/LDEditor.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"LDEditor.d.ts","sourceRoot":"","sources":["../../../src/components/LDEditor/LDEditor.tsx"],"names":[],"mappings":"AAiDA,OAAO,EAEL,KAAK,SAAS,EAGf,MAAM,kBAAkB,CAAC;AAW1B,QAAA,MAAM,UAAU,2FAWN,CAAC;AAEX,qBAAqB;AACrB,MAAM,MAAM,gBAAgB,GAAG,CAAC,OAAO,UAAU,CAAC,CAAC,MAAM,CAAC,CAAC;AA2P3D,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;;;;;;OASG;IACH,eAAe,CAAC,EAAE,gBAAgB,CAAC;IACnC,0DAA0D;IAC1D,iBAAiB,CAAC,EAAE,CAAC,QAAQ,EAAE;QAC7B,UAAU,EAAE,MAAM,CAAC;QACnB,QAAQ,EAAE,MAAM,CAAC;KAClB,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC7B;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC7B,4DAA4D;IAC5D,iBAAiB,CAAC,EAAE,SAAS,EAAE,CAAC;IAChC,oDAAoD;IACpD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,wDAAwD;IACxD,YAAY,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;CAClD;AAuXD,QAAA,MAAM,QAAQ,GAAI,qKAYf,aAAa,4CA0Cf,CAAC;AA2lBF,eAAe,QAAQ,CAAC"}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { Fragment, Slice, } from '@tiptap/pm/model';
|
|
4
|
-
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
|
4
|
+
import { Plugin, PluginKey, } from '@tiptap/pm/state';
|
|
5
5
|
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
|
6
6
|
import { defaultBlockAt, Extension, useEditorState, } from '@tiptap/react';
|
|
7
7
|
import { BubbleMenu } from '@tiptap/react/menus';
|
|
8
8
|
import { AlignCenter, AlignJustify, AlignLeft, AlignRight, Bold, ChevronDown, Image as ImageIcon, Italic, Link2, List, ListOrdered, Redo, Strikethrough, Table as TableIcon, Underline, Undo, Unlink, } from 'lucide-react';
|
|
9
9
|
import { useEffect, useRef, useState } from 'react';
|
|
10
10
|
import EditorBase, { useEditorContext } from '../../components/LDEditor/EditorBase.js';
|
|
11
|
-
import { createEditorExtensions } from '../../components/LDEditor/extensions.js';
|
|
11
|
+
import { createEditorExtensions, } from '../../components/LDEditor/extensions.js';
|
|
12
12
|
import LDToast from '../../components/LDToast/LDToast.js';
|
|
13
13
|
import { FILE_EXTENSIONS, TOOLBAR_ALLOWED_EXTENSIONS, TOOLBAR_MAX_SIZE, } from '../../constants/file.js';
|
|
14
14
|
import cn from '../../utils/cn.js';
|
|
@@ -25,19 +25,121 @@ const FONT_SIZES = [
|
|
|
25
25
|
'36px',
|
|
26
26
|
'40px',
|
|
27
27
|
];
|
|
28
|
+
// 글자 색 프리셋. 값은 LDS 팔레트(src/styles/color.css) 토큰의 hex이고, 저장 값도 hex(#rrggbb)다.
|
|
29
|
+
// 목록과 툴바에는 hex가 아니라 label을 보여 준다.
|
|
30
|
+
// 첫 항목(검정)이 글 기본 색이다(editor.css 의 .ld-editor-render 기본 글자 색). 고르면 색 mark를 걷어 낸다.
|
|
31
|
+
// 무채색은 검정·회색 두 단계만 둔다 — 그 사이 값은 눈으로 구분되지 않는다.
|
|
32
|
+
const DEFAULT_COLOR = { label: '검정', value: '#191f28' }; // neutral900
|
|
28
33
|
const COLORS = [
|
|
29
|
-
|
|
30
|
-
{ label: '
|
|
31
|
-
{ label: '
|
|
32
|
-
{ label: '
|
|
33
|
-
{ label: '
|
|
34
|
+
DEFAULT_COLOR,
|
|
35
|
+
{ label: '회색', value: '#848a92' }, // neutral500
|
|
36
|
+
{ label: '빨강', value: '#c70000' }, // red800
|
|
37
|
+
{ label: '주황', value: '#e64500' }, // orange700
|
|
38
|
+
{ label: '노랑', value: '#c58e02' }, // yellow800 (흰 배경에서 읽히는 진한 값)
|
|
39
|
+
{ label: '초록', value: '#008f2d' }, // green800
|
|
40
|
+
{ label: '파랑', value: '#003fff' }, // blue500
|
|
41
|
+
{ label: '남색', value: '#011960' }, // blue800
|
|
42
|
+
{ label: '보라', value: '#7b40c7' }, // purple500
|
|
34
43
|
];
|
|
44
|
+
// HTML을 읽어 들이면 색이 rgb()로 올 수 있어 hex로 맞춰 비교한다.
|
|
45
|
+
const toHex = (color) => {
|
|
46
|
+
const rgb = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(color);
|
|
47
|
+
return rgb
|
|
48
|
+
? `#${[rgb[1], rgb[2], rgb[3]]
|
|
49
|
+
.map((channel) => Number(channel).toString(16).padStart(2, '0'))
|
|
50
|
+
.join('')}`
|
|
51
|
+
: color.toLowerCase();
|
|
52
|
+
};
|
|
53
|
+
const getColorLabel = (color) => {
|
|
54
|
+
if (!color)
|
|
55
|
+
return DEFAULT_COLOR.label;
|
|
56
|
+
const hex = toHex(color);
|
|
57
|
+
return COLORS.find((item) => item.value === hex)?.label ?? hex;
|
|
58
|
+
};
|
|
59
|
+
// '캡션' 프리셋: 문단 전체 텍스트에 글자 크기와 색 mark(textStyle)를 준다. 문단 노드는 그대로다.
|
|
60
|
+
const CAPTION = { fontSize: '12px', color: '#848a92' }; // 색은 neutral500
|
|
35
61
|
const HEADINGS = [
|
|
36
62
|
{ label: '본문', level: 0 },
|
|
37
63
|
{ label: '제목 1', level: 1 },
|
|
38
64
|
{ label: '제목 2', level: 2 },
|
|
39
65
|
{ label: '제목 3', level: 3 },
|
|
40
66
|
];
|
|
67
|
+
const isCaptionMarks = (marks) => {
|
|
68
|
+
const attrs = marks.find((mark) => mark.type.name === 'textStyle')?.attrs;
|
|
69
|
+
return (attrs?.fontSize === CAPTION.fontSize &&
|
|
70
|
+
!!attrs.color &&
|
|
71
|
+
toHex(attrs.color) === CAPTION.color);
|
|
72
|
+
};
|
|
73
|
+
// 커서가 있는 문단이 캡션인지: 문단의 글자가 전부 캡션 값이다.
|
|
74
|
+
// 빈 문단은 글자가 없어 커서에 걸어 둔 mark(storedMarks)로 본다.
|
|
75
|
+
const isCaptionBlock = (editor) => {
|
|
76
|
+
const { state } = editor;
|
|
77
|
+
const block = state.selection.$from.parent;
|
|
78
|
+
if (block.type.name !== 'paragraph')
|
|
79
|
+
return false;
|
|
80
|
+
if (block.content.size === 0)
|
|
81
|
+
return isCaptionMarks(state.storedMarks ?? []);
|
|
82
|
+
let caption = block.textContent.length > 0;
|
|
83
|
+
block.forEach((node) => {
|
|
84
|
+
if (node.isText && !isCaptionMarks(node.marks))
|
|
85
|
+
caption = false;
|
|
86
|
+
});
|
|
87
|
+
return caption;
|
|
88
|
+
};
|
|
89
|
+
// 선택이 걸친 문단 전부에 텍스트 스타일을 준다. 캡션은 문단에 글자 크기·색 mark를 거는 것이라,
|
|
90
|
+
// 캡션으로 바꾸면 mark를 걸고 캡션에서 다른 스타일로 바꾸면 걷는다(캡션이 아닌 글에 사용자가 준 크기·색은 둔다).
|
|
91
|
+
// 선택은 건드리지 않고 한 트랜잭션으로 처리해 실행 취소도 한 번이다.
|
|
92
|
+
const setTextStyle = (editor, value, wasCaption) => {
|
|
93
|
+
const chain = editor.chain().focus();
|
|
94
|
+
if (value === 0 || value === 'caption')
|
|
95
|
+
chain.setParagraph();
|
|
96
|
+
else
|
|
97
|
+
chain.setHeading({ level: value });
|
|
98
|
+
if (value !== 'caption' && !wasCaption) {
|
|
99
|
+
chain.run();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
chain
|
|
103
|
+
.command(({ tr }) => {
|
|
104
|
+
const { $from, $to, empty } = tr.selection;
|
|
105
|
+
// 이미지 등 블록을 선택했으면 걸 글자가 없다
|
|
106
|
+
if (!$from.parent.isTextblock)
|
|
107
|
+
return true;
|
|
108
|
+
// 글자 크기·색은 textStyle mark 하나에 담긴다. 걷은 뒤 캡션이면 캡션 값만 건다.
|
|
109
|
+
const textStyle = editor.schema.marks.textStyle;
|
|
110
|
+
tr.removeMark($from.start(), $to.end(), textStyle);
|
|
111
|
+
if (value === 'caption') {
|
|
112
|
+
tr.addMark($from.start(), $to.end(), textStyle.create(CAPTION));
|
|
113
|
+
}
|
|
114
|
+
// 빈 문단에는 mark가 붙을 글자가 없다. 이어 쓸 글자에 걸리도록 커서에 둔다.
|
|
115
|
+
if (empty) {
|
|
116
|
+
if (value === 'caption')
|
|
117
|
+
tr.addStoredMark(textStyle.create(CAPTION));
|
|
118
|
+
else
|
|
119
|
+
tr.removeStoredMark(textStyle);
|
|
120
|
+
}
|
|
121
|
+
return true;
|
|
122
|
+
})
|
|
123
|
+
.run();
|
|
124
|
+
};
|
|
125
|
+
// 툴바에 보일 글자 크기·색. 빈 커서는 이어 쓸 글자에 실제로 걸리는 mark를 본다 — 커서에 걸어 둔 mark(storedMarks)가
|
|
126
|
+
// 있으면 그것만, 없으면 커서 앞 글자의 mark. tiptap getAttributes는 둘을 합쳐 보므로 빈 커서에서 색을 걷어도 앞 글자 색이 보인다.
|
|
127
|
+
const getTextStyleAttrs = (editor) => {
|
|
128
|
+
const { storedMarks, selection } = editor.state;
|
|
129
|
+
const attrs = selection.empty
|
|
130
|
+
? (storedMarks ?? selection.$head.marks()).find((mark) => mark.type.name === 'textStyle')?.attrs
|
|
131
|
+
: editor.getAttributes('textStyle');
|
|
132
|
+
return (attrs ?? {});
|
|
133
|
+
};
|
|
134
|
+
// tiptap의 removeEmptyTextStyle은 문서 속 mark만 걷는다. 빈 커서에서 색을 걷으면 속성 없는 textStyle이
|
|
135
|
+
// 커서(storedMarks)에 남아 이어 쓴 글자가 빈 <span>으로 저장되므로 함께 걷는다.
|
|
136
|
+
const removeEmptyStoredTextStyle = ({ tr }) => {
|
|
137
|
+
const mark = tr.storedMarks?.find((item) => item.type.name === 'textStyle');
|
|
138
|
+
if (mark && Object.values(mark.attrs).every((value) => value == null)) {
|
|
139
|
+
tr.removeStoredMark(mark);
|
|
140
|
+
}
|
|
141
|
+
return true;
|
|
142
|
+
};
|
|
41
143
|
const TABLE_GRID_MAX = 5;
|
|
42
144
|
const TABLE_ACTIONS = [
|
|
43
145
|
{ label: '위에 행 추가', run: (e) => e.chain().focus().addRowBefore().run() },
|
|
@@ -75,6 +177,17 @@ const TableGridPicker = ({ onSelect, }) => {
|
|
|
75
177
|
const iconBtn = 'p-0.5 rounded-lg cursor-pointer transition hover:bg-mono100 disabled:opacity-30 disabled:cursor-not-allowed';
|
|
76
178
|
const activeBtn = 'bg-mono100';
|
|
77
179
|
const LDDivider = () => _jsx("div", { className: "mx-1 h-6 w-px bg-neutral200" });
|
|
180
|
+
// 색 점. 색 mark가 없으면(기본 색) 속이 빈 점.
|
|
181
|
+
const ColorDot = ({ color }) => (_jsx("span", { className: cn('size-4 rounded-full', !color && 'border border-neutral300 bg-white'), style: color ? { backgroundColor: color } : undefined }));
|
|
182
|
+
// 글자 색 목록: 색 이름과 색 점. 기본 색(검정)을 고르면 색 mark를 걷어 내 HTML에 색이 남지 않는다.
|
|
183
|
+
const ColorOptions = ({ editor, close, }) => (_jsx(_Fragment, { children: COLORS.map(({ label, value }) => (_jsxs("button", { type: "button", onClick: (e) => {
|
|
184
|
+
e.preventDefault();
|
|
185
|
+
const chain = editor.chain().focus();
|
|
186
|
+
(value === DEFAULT_COLOR.value
|
|
187
|
+
? chain.unsetColor().command(removeEmptyStoredTextStyle)
|
|
188
|
+
: chain.setColor(value)).run();
|
|
189
|
+
close();
|
|
190
|
+
}, className: "flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-sm first:rounded-t-lg last:rounded-b-lg hover:bg-mono100", children: [_jsx(ColorDot, { color: value }), _jsx("span", { children: label })] }, value))) }));
|
|
78
191
|
// 구분선으로 나뉜 버튼 묶음. 툴바가 flex-wrap이라 묶음 자체가 하나의 flex 아이템이
|
|
79
192
|
// 되어, 폭이 모자라면 묶음 단위로 함께 다음 줄로 내려간다. 구분선은 묶음의 끝에
|
|
80
193
|
// 두어 줄이 바뀌어도 새 줄이 구분선으로 시작하지 않게 한다.
|
|
@@ -105,15 +218,30 @@ const uploadImageFile = async (file, { handleImageUpload, allowedExtensions = TO
|
|
|
105
218
|
throw new Error('이미지 업로드에 실패했습니다.');
|
|
106
219
|
return src;
|
|
107
220
|
};
|
|
108
|
-
//
|
|
109
|
-
// 선택자로 가려내야 하는데, 그 조건이 빗나가면 래퍼와 이미지 폭이 어긋나 리사이즈
|
|
110
|
-
// 핸들이 이미지에서 떨어진다. 값으로 넣으면 구조와 무관하게 결과가 같다.
|
|
111
|
-
// 폭을 못 재면(숨겨진 컨테이너 등) undefined를 돌려 원본 크기로 둔다.
|
|
221
|
+
// 편집 영역 내용 폭(px). 폭을 못 재면(숨겨진 컨테이너 등) undefined.
|
|
112
222
|
const getContentWidth = (dom) => {
|
|
113
223
|
const { paddingLeft, paddingRight } = getComputedStyle(dom);
|
|
114
224
|
const width = Math.round(dom.clientWidth - parseFloat(paddingLeft) - parseFloat(paddingRight));
|
|
115
225
|
return width > 0 ? width : undefined;
|
|
116
226
|
};
|
|
227
|
+
const getNaturalWidth = (src) => new Promise((resolve) => {
|
|
228
|
+
const img = new Image();
|
|
229
|
+
img.onload = () => resolve(img.naturalWidth || undefined);
|
|
230
|
+
img.onerror = () => resolve(undefined);
|
|
231
|
+
img.src = src;
|
|
232
|
+
});
|
|
233
|
+
// 새로 넣는 이미지의 너비: 원래 너비가 내용 폭보다 작으면 원래 너비(px), 같거나 크면 꽉 채움('100%').
|
|
234
|
+
// 원래 너비를 못 재면 null(지정 없음 — 원본 크기, 내용 폭보다 크면 내용 폭).
|
|
235
|
+
const getInsertWidth = async (src, contentWidth) => {
|
|
236
|
+
const natural = await getNaturalWidth(src);
|
|
237
|
+
if (!natural)
|
|
238
|
+
return null;
|
|
239
|
+
return contentWidth !== undefined && natural >= contentWidth
|
|
240
|
+
? '100%'
|
|
241
|
+
: natural;
|
|
242
|
+
};
|
|
243
|
+
// 업로드한 URL마다 너비를 정한다. 실패한 자리(null)는 그대로 둔다.
|
|
244
|
+
const withInsertWidths = (srcs, contentWidth) => Promise.all(srcs.map(async (src) => src ? { src, width: await getInsertWidth(src, contentWidth) } : null));
|
|
117
245
|
// ── 이미지 붙여넣기 ──
|
|
118
246
|
const isDataUrl = (src) => typeof src === 'string' && src.startsWith('data:');
|
|
119
247
|
// 상대 경로는 현재 페이지 기준으로 푼다. 주소로 읽을 수 없으면 허용하지 않는다.
|
|
@@ -153,7 +281,7 @@ const uploadAll = async (uploads) => {
|
|
|
153
281
|
LDToast.error(failed.reason.message);
|
|
154
282
|
return results.map((result) => result.status === 'fulfilled' ? result.value : null);
|
|
155
283
|
};
|
|
156
|
-
// urls에 있는 이미지의 src를 업로드 URL로 바꾸고, 값이 null(업로드 실패)인 이미지는 뺀다.
|
|
284
|
+
// urls에 있는 이미지의 src를 업로드 URL로(너비도 새로 정해) 바꾸고, 값이 null(업로드 실패)인 이미지는 뺀다.
|
|
157
285
|
const replaceImages = (fragment, urls) => {
|
|
158
286
|
const nodes = [];
|
|
159
287
|
fragment.forEach((node) => {
|
|
@@ -161,9 +289,10 @@ const replaceImages = (fragment, urls) => {
|
|
|
161
289
|
if (node.type.name === 'image' &&
|
|
162
290
|
typeof src === 'string' &&
|
|
163
291
|
urls.has(src)) {
|
|
164
|
-
const
|
|
165
|
-
if (
|
|
166
|
-
nodes.push(node.type.create({ ...node.attrs,
|
|
292
|
+
const uploaded = urls.get(src);
|
|
293
|
+
if (uploaded) {
|
|
294
|
+
nodes.push(node.type.create({ ...node.attrs, ...uploaded }));
|
|
295
|
+
}
|
|
167
296
|
return;
|
|
168
297
|
}
|
|
169
298
|
nodes.push(node.childCount ? node.copy(replaceImages(node.content, urls)) : node);
|
|
@@ -290,8 +419,9 @@ const ImagePaste = Extension.create({
|
|
|
290
419
|
? dataUrlToFile(src).then((file) => uploadImageFile(file, config))
|
|
291
420
|
: (copiedImage
|
|
292
421
|
? Promise.resolve(copiedImage)
|
|
293
|
-
: urlToFile(src)).then((file) => uploadImageFile(file, config)))).then((srcs) => {
|
|
294
|
-
const
|
|
422
|
+
: urlToFile(src)).then((file) => uploadImageFile(file, config)))).then(async (srcs) => {
|
|
423
|
+
const uploaded = await withInsertWidths(srcs, getContentWidth(view.dom));
|
|
424
|
+
const urls = new Map(list.map((src, index) => [src, uploaded[index] ?? null]));
|
|
295
425
|
return new Slice(replaceImages(slice.content, urls), slice.openStart, slice.openEnd);
|
|
296
426
|
}));
|
|
297
427
|
return true;
|
|
@@ -299,15 +429,15 @@ const ImagePaste = Extension.create({
|
|
|
299
429
|
// Office는 HTML과 함께 선택 영역을 찍은 PNG를 files에 넣는다. HTML이 있으면 파일은 무시한다.
|
|
300
430
|
if (html || !files.length)
|
|
301
431
|
return false;
|
|
302
|
-
pasteAfterUpload(view, uploadAll(files.map((file) => uploadImageFile(file, config))).then((srcs) => {
|
|
432
|
+
pasteAfterUpload(view, uploadAll(files.map((file) => uploadImageFile(file, config))).then(async (srcs) => {
|
|
433
|
+
const uploaded = await withInsertWidths(srcs, getContentWidth(view.dom));
|
|
303
434
|
const nodes = files.flatMap((file, index) => {
|
|
304
|
-
const
|
|
305
|
-
return
|
|
435
|
+
const image = uploaded[index];
|
|
436
|
+
return image
|
|
306
437
|
? [
|
|
307
438
|
view.state.schema.node('image', {
|
|
308
|
-
|
|
439
|
+
...image,
|
|
309
440
|
alt: file.name,
|
|
310
|
-
width: getContentWidth(view.dom),
|
|
311
441
|
}),
|
|
312
442
|
]
|
|
313
443
|
: [];
|
|
@@ -321,7 +451,7 @@ const ImagePaste = Extension.create({
|
|
|
321
451
|
];
|
|
322
452
|
},
|
|
323
453
|
});
|
|
324
|
-
const LDEditor = ({ content, onChange, className, handleImageUpload, allowedImageHosts, allowedExtensions, maxSize, maxImageSize, }) => {
|
|
454
|
+
const LDEditor = ({ content, onChange, className, stickyToolbar, toolbarClassName, defaultFontSize, handleImageUpload, allowedImageHosts, allowedExtensions, maxSize, maxImageSize, }) => {
|
|
325
455
|
// 에디터는 첫 렌더의 extensions로 한 번만 만들어진다. 붙여넣기는 최신 props를 ref로 읽는다.
|
|
326
456
|
const pasteConfigRef = useRef({
|
|
327
457
|
handleImageUpload,
|
|
@@ -342,9 +472,9 @@ const LDEditor = ({ content, onChange, className, handleImageUpload, allowedImag
|
|
|
342
472
|
return (_jsxs(EditorBase.Root, { extensions: [
|
|
343
473
|
...createEditorExtensions(),
|
|
344
474
|
ImagePaste.configure({ getConfig: () => pasteConfigRef.current }),
|
|
345
|
-
], content: content, onChange: onChange, className: className, children: [_jsx(EditorToolbar, { handleImageUpload: handleImageUpload, allowedExtensions: allowedExtensions, maxSize: maxSize, maxImageSize: maxImageSize }), _jsx(EditorBase.Content, {})] }));
|
|
475
|
+
], content: content, onChange: onChange, className: className, defaultFontSize: defaultFontSize, children: [_jsx(EditorToolbar, { stickyToolbar: stickyToolbar, toolbarClassName: toolbarClassName, defaultFontSize: defaultFontSize, handleImageUpload: handleImageUpload, allowedExtensions: allowedExtensions, maxSize: maxSize, maxImageSize: maxImageSize }), _jsx(EditorBase.Content, {})] }));
|
|
346
476
|
};
|
|
347
|
-
const EditorToolbar = (uploadConfig) => {
|
|
477
|
+
const EditorToolbar = ({ stickyToolbar = false, toolbarClassName, defaultFontSize = '18px', ...uploadConfig }) => {
|
|
348
478
|
const [linkUrl, setLinkUrl] = useState('');
|
|
349
479
|
const [isMobile, setIsMobile] = useState(false);
|
|
350
480
|
const { editor } = useEditorContext();
|
|
@@ -361,9 +491,11 @@ const EditorToolbar = (uploadConfig) => {
|
|
|
361
491
|
selector: ({ editor }) => {
|
|
362
492
|
if (!editor)
|
|
363
493
|
return null;
|
|
494
|
+
const textStyle = getTextStyleAttrs(editor);
|
|
364
495
|
return {
|
|
365
|
-
fontSize:
|
|
366
|
-
color:
|
|
496
|
+
fontSize: textStyle.fontSize || defaultFontSize,
|
|
497
|
+
color: textStyle.color ?? undefined,
|
|
498
|
+
isCaption: isCaptionBlock(editor),
|
|
367
499
|
headingLevel: [1, 2, 3].find((l) => editor.isActive('heading', { level: l })),
|
|
368
500
|
isBold: editor.isActive('bold'),
|
|
369
501
|
isItalic: editor.isActive('italic'),
|
|
@@ -392,12 +524,16 @@ const EditorToolbar = (uploadConfig) => {
|
|
|
392
524
|
if (!files?.[0] || !editor)
|
|
393
525
|
return;
|
|
394
526
|
const file = files[0];
|
|
527
|
+
const contentWidth = getContentWidth(editor.view.dom);
|
|
395
528
|
try {
|
|
396
529
|
const src = await uploadImageFile(file, uploadConfig);
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
530
|
+
const width = await getInsertWidth(src, contentWidth);
|
|
531
|
+
// 업로드하는 사이 에디터가 사라졌으면(페이지 이동 등) 넣을 곳이 없다
|
|
532
|
+
if (editor.isDestroyed)
|
|
533
|
+
return;
|
|
534
|
+
editor.commands.insertContent({
|
|
535
|
+
type: 'image',
|
|
536
|
+
attrs: { src, alt: file.name, width },
|
|
401
537
|
});
|
|
402
538
|
}
|
|
403
539
|
catch (error) {
|
|
@@ -426,26 +562,29 @@ const EditorToolbar = (uploadConfig) => {
|
|
|
426
562
|
else
|
|
427
563
|
chain.unsetLink().run();
|
|
428
564
|
};
|
|
429
|
-
return (_jsxs(_Fragment, { children: [_jsxs(EditorBase.Toolbar, { className:
|
|
565
|
+
return (_jsxs(_Fragment, { children: [_jsxs(EditorBase.Toolbar, { className: cn('gap-2 rounded-t-md border border-neutral200 px-4 py-3',
|
|
566
|
+
// 이미지 리사이즈 핸들(z-10)보다 위, 내용이 비쳐 겹쳐 보이지 않게 배경을 채운다.
|
|
567
|
+
stickyToolbar && 'sticky top-0 z-20 bg-white', toolbarClassName), children: [_jsxs(ToolbarGroup, { children: [_jsx(EditorBase.Toolbar.Dropdown, { className: "min-w-30 rounded-lg border border-neutral200 bg-white shadow-lg", trigger: ({ toggle }) => (
|
|
430
568
|
// aria-label이 없으면 접근성 이름이 현재 선택값('본문'·'제목 1'…)이 되어,
|
|
431
569
|
// 드롭다운이 열렸을 때 트리거와 옵션이 같은 이름을 갖는다.
|
|
432
570
|
// 고정 라벨을 줘서 트리거를 항상 같은 이름으로 가리킬 수 있게 한다.
|
|
433
|
-
_jsx(EditorBase.Toolbar.LDButton, { "aria-label": "\uC81C\uBAA9 \uC218\uC900", onClick: toggle, className: cn(iconBtn, 'flex items-center gap-5 px-2'), children: _jsx("span", { className: "min-w-10 whitespace-nowrap", children: state.
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
editor.chain().focus().toggleHeading({ level }).run();
|
|
439
|
-
close();
|
|
440
|
-
}, className: "w-full cursor-pointer px-3 py-2 text-left text-sm first:rounded-t-lg last:rounded-b-lg hover:bg-mono100", children: label }, level))) }), _jsx(LDDivider, {})] }), !isMobile && (_jsxs(_Fragment, { children: [_jsxs(ToolbarGroup, { children: [_jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleBold().run(), className: cn(iconBtn, state.isBold && activeBtn), "aria-label": "\uAD75\uAC8C", children: _jsx(Bold, { className: "size-5" }) }), _jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleItalic().run(), className: cn(iconBtn, state.isItalic && activeBtn), "aria-label": "\uAE30\uC6B8\uC784", children: _jsx(Italic, { className: "size-5" }) }), _jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleUnderline().run(), className: cn(iconBtn, state.isUnderline && activeBtn), "aria-label": "\uBC11\uC904", children: _jsx(Underline, { className: "size-5" }) }), _jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleStrike().run(), className: cn(iconBtn, state.isStrike && activeBtn), "aria-label": "\uCDE8\uC18C\uC120", children: _jsx(Strikethrough, { className: "size-5" }) }), _jsx(LDDivider, {})] }), _jsxs(ToolbarGroup, { children: [_jsx(EditorBase.Toolbar.Dropdown, { className: "min-w-25 rounded-lg border border-neutral200 bg-white shadow-lg", trigger: ({ toggle }) => (_jsxs(EditorBase.Toolbar.LDButton, { "aria-label": "\uAE00\uC790 \uD06C\uAE30", onClick: toggle, className: cn(iconBtn, 'flex items-center gap-1 px-2'), children: [_jsx("span", { className: "text-sm whitespace-nowrap", children: state.fontSize }), _jsx(ChevronDown, { className: "size-4" })] })), children: ({ close }) => FONT_SIZES.map((size) => (_jsx("button", { type: "button", onClick: (e) => {
|
|
571
|
+
_jsx(EditorBase.Toolbar.LDButton, { "aria-label": "\uC81C\uBAA9 \uC218\uC900", onClick: toggle, className: cn(iconBtn, 'flex items-center gap-5 px-2'), children: _jsx("span", { className: "min-w-10 whitespace-nowrap", children: state.isCaption
|
|
572
|
+
? '캡션'
|
|
573
|
+
: state.headingLevel
|
|
574
|
+
? `제목 ${state.headingLevel}`
|
|
575
|
+
: '본문' }) })), children: ({ close }) => (_jsxs(_Fragment, { children: [HEADINGS.map(({ label, level }) => (_jsx("button", { type: "button", onClick: (e) => {
|
|
441
576
|
e.preventDefault();
|
|
442
|
-
editor.
|
|
577
|
+
setTextStyle(editor, level, state.isCaption);
|
|
443
578
|
close();
|
|
444
|
-
}, className: "w-full cursor-pointer px-3 py-2 text-left text-sm first:rounded-t-lg
|
|
579
|
+
}, className: "w-full cursor-pointer px-3 py-2 text-left text-sm first:rounded-t-lg hover:bg-mono100", children: label }, level))), _jsx("button", { type: "button", onClick: (e) => {
|
|
445
580
|
e.preventDefault();
|
|
446
|
-
editor.
|
|
581
|
+
setTextStyle(editor, 'caption', state.isCaption);
|
|
447
582
|
close();
|
|
448
|
-
}, className: "
|
|
583
|
+
}, className: "w-full cursor-pointer rounded-b-lg px-3 py-2 text-left text-sm hover:bg-mono100", children: "\uCEA1\uC158" })] })) }), _jsx(LDDivider, {})] }), !isMobile && (_jsxs(_Fragment, { children: [_jsxs(ToolbarGroup, { children: [_jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleBold().run(), className: cn(iconBtn, state.isBold && activeBtn), "aria-label": "\uAD75\uAC8C", children: _jsx(Bold, { className: "size-5" }) }), _jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleItalic().run(), className: cn(iconBtn, state.isItalic && activeBtn), "aria-label": "\uAE30\uC6B8\uC784", children: _jsx(Italic, { className: "size-5" }) }), _jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleUnderline().run(), className: cn(iconBtn, state.isUnderline && activeBtn), "aria-label": "\uBC11\uC904", children: _jsx(Underline, { className: "size-5" }) }), _jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleStrike().run(), className: cn(iconBtn, state.isStrike && activeBtn), "aria-label": "\uCDE8\uC18C\uC120", children: _jsx(Strikethrough, { className: "size-5" }) }), _jsx(LDDivider, {})] }), _jsxs(ToolbarGroup, { children: [_jsx(EditorBase.Toolbar.Dropdown, { className: "min-w-25 rounded-lg border border-neutral200 bg-white shadow-lg", trigger: ({ toggle }) => (_jsxs(EditorBase.Toolbar.LDButton, { "aria-label": "\uAE00\uC790 \uD06C\uAE30", onClick: toggle, className: cn(iconBtn, 'flex items-center gap-1 px-2'), children: [_jsx("span", { className: "text-sm whitespace-nowrap", children: state.fontSize }), _jsx(ChevronDown, { className: "size-4" })] })), children: ({ close }) => FONT_SIZES.map((size) => (_jsx("button", { type: "button", onClick: (e) => {
|
|
584
|
+
e.preventDefault();
|
|
585
|
+
editor.chain().focus().setFontSize(size).run();
|
|
586
|
+
close();
|
|
587
|
+
}, className: "w-full cursor-pointer px-3 py-2 text-left text-sm first:rounded-t-lg last:rounded-b-lg hover:bg-mono100", children: size }, size))) }), _jsx(EditorBase.Toolbar.Dropdown, { className: "min-w-35 rounded-lg border border-neutral200 bg-white shadow-lg", trigger: ({ toggle }) => (_jsxs(EditorBase.Toolbar.LDButton, { "aria-label": "\uAE00\uC790 \uC0C9", onClick: toggle, className: cn(iconBtn, 'flex items-center gap-2 px-2'), children: [_jsx(ColorDot, { color: state.color || DEFAULT_COLOR.value }), getColorLabel(state.color), _jsx(ChevronDown, { className: "size-4" })] })), children: ({ close }) => _jsx(ColorOptions, { editor: editor, close: close }) }), _jsx(LDDivider, {})] }), _jsxs(ToolbarGroup, { children: [['left', 'center', 'right', 'justify'].map((align) => {
|
|
449
588
|
const Icon = {
|
|
450
589
|
left: AlignLeft,
|
|
451
590
|
center: AlignCenter,
|
|
@@ -490,11 +629,7 @@ const EditorToolbar = (uploadConfig) => {
|
|
|
490
629
|
} })) }), _jsxs("label", { className: cn(iconBtn, 'relative'), children: [_jsx(ImageIcon, { className: "size-5" }), _jsx("input", { type: "file", accept: "image/*", "aria-label": "\uC774\uBBF8\uC9C0 \uC5C5\uB85C\uB4DC", className: "absolute inset-0 size-full cursor-pointer opacity-0", onChange: (e) => {
|
|
491
630
|
handleImageUploadAction(e.target.files);
|
|
492
631
|
e.target.value = '';
|
|
493
|
-
} })] })] })] }), isMobile && (_jsxs(BubbleMenu, { editor: editor, className: "flex items-center gap-1 rounded-lg border border-neutral200 bg-white p-1 shadow-lg", children: [_jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleBold().run(), className: cn(iconBtn, state.isBold && activeBtn), "aria-label": "\uAD75\uAC8C", children: _jsx(Bold, { className: "size-5" }) }), _jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleItalic().run(), className: cn(iconBtn, state.isItalic && activeBtn), "aria-label": "\uAE30\uC6B8\uC784", children: _jsx(Italic, { className: "size-5" }) }), _jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleUnderline().run(), className: cn(iconBtn, state.isUnderline && activeBtn), "aria-label": "\uBC11\uC904", children: _jsx(Underline, { className: "size-5" }) }), _jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleStrike().run(), className: cn(iconBtn, state.isStrike && activeBtn), "aria-label": "\uCDE8\uC18C\uC120", children: _jsx(Strikethrough, { className: "size-5" }) }), _jsx(EditorBase.Toolbar.Dropdown, { className: "min-w-35 rounded-lg border border-neutral200 bg-white shadow-lg", trigger: ({ toggle }) => (_jsxs(EditorBase.Toolbar.LDButton, { onClick: toggle, className: cn(iconBtn, 'flex items-center gap-1 px-2'), "aria-label": "\uAE00\uC790 \uC0C9", children: [_jsx(
|
|
494
|
-
e.preventDefault();
|
|
495
|
-
editor.chain().focus().setColor(color.value).run();
|
|
496
|
-
close();
|
|
497
|
-
}, className: "flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-sm first:rounded-t-lg last:rounded-b-lg hover:bg-mono100", children: [_jsx("span", { className: "size-4 rounded-full", style: { backgroundColor: color.value } }), _jsx("span", { children: color.label })] }, color.value))) }), _jsx(EditorBase.Toolbar.Dropdown, { className: "min-w-60 rounded-lg border border-neutral200 bg-white p-3 shadow-lg", trigger: ({ toggle }) => (_jsx(EditorBase.Toolbar.LDButton, { onClick: () => {
|
|
632
|
+
} })] })] })] }), isMobile && (_jsxs(BubbleMenu, { editor: editor, className: "flex items-center gap-1 rounded-lg border border-neutral200 bg-white p-1 shadow-lg", children: [_jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleBold().run(), className: cn(iconBtn, state.isBold && activeBtn), "aria-label": "\uAD75\uAC8C", children: _jsx(Bold, { className: "size-5" }) }), _jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleItalic().run(), className: cn(iconBtn, state.isItalic && activeBtn), "aria-label": "\uAE30\uC6B8\uC784", children: _jsx(Italic, { className: "size-5" }) }), _jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleUnderline().run(), className: cn(iconBtn, state.isUnderline && activeBtn), "aria-label": "\uBC11\uC904", children: _jsx(Underline, { className: "size-5" }) }), _jsx(EditorBase.Toolbar.LDButton, { onClick: () => editor.chain().focus().toggleStrike().run(), className: cn(iconBtn, state.isStrike && activeBtn), "aria-label": "\uCDE8\uC18C\uC120", children: _jsx(Strikethrough, { className: "size-5" }) }), _jsx(EditorBase.Toolbar.Dropdown, { className: "min-w-35 rounded-lg border border-neutral200 bg-white shadow-lg", trigger: ({ toggle }) => (_jsxs(EditorBase.Toolbar.LDButton, { onClick: toggle, className: cn(iconBtn, 'flex items-center gap-1 px-2'), "aria-label": "\uAE00\uC790 \uC0C9", children: [_jsx(ColorDot, { color: state.color || DEFAULT_COLOR.value }), _jsx(ChevronDown, { className: "size-4" })] })), children: ({ close }) => _jsx(ColorOptions, { editor: editor, close: close }) }), _jsx(EditorBase.Toolbar.Dropdown, { className: "min-w-60 rounded-lg border border-neutral200 bg-white p-3 shadow-lg", trigger: ({ toggle }) => (_jsx(EditorBase.Toolbar.LDButton, { onClick: () => {
|
|
498
633
|
setLinkUrl(getLinkHref());
|
|
499
634
|
toggle();
|
|
500
635
|
}, className: cn(iconBtn, state.isLink && activeBtn), "aria-label": "\uB9C1\uD06C", children: _jsx(Link2, { className: "size-5" }) })), children: ({ close }) => {
|
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
import type { LDEditorFontSize } from '../../components/LDEditor/LDEditor.js';
|
|
1
2
|
export interface LDViewerProps {
|
|
2
3
|
content: string;
|
|
3
4
|
className?: string;
|
|
5
|
+
/**
|
|
6
|
+
* 본문 기본 글자 크기. 기본 `'18px'`(prose-lg 기본과 같음). prose 루트에 인라인 `font-size`로
|
|
7
|
+
* 적용한다. 정적 렌더는 prose 루트(`div.prose.prose-lg`)에 `ld-editor-render` 클래스와 같은 `font-size`를 준다.
|
|
8
|
+
*/
|
|
9
|
+
defaultFontSize?: LDEditorFontSize;
|
|
4
10
|
}
|
|
5
|
-
declare const LDViewer: ({ content, className }: LDViewerProps) => import("react/jsx-runtime").JSX.Element;
|
|
11
|
+
declare const LDViewer: ({ content, className, defaultFontSize }: LDViewerProps) => import("react/jsx-runtime").JSX.Element;
|
|
6
12
|
export default LDViewer;
|
|
7
13
|
//# sourceMappingURL=LDViewer.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"LDViewer.d.ts","sourceRoot":"","sources":["../../../src/components/LDEditor/LDViewer.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"LDViewer.d.ts","sourceRoot":"","sources":["../../../src/components/LDEditor/LDViewer.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gCAAgC,CAAC;AAGvE,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,eAAe,CAAC,EAAE,gBAAgB,CAAC;CACpC;AAED,QAAA,MAAM,QAAQ,GAAI,yCAAyC,aAAa,4CAevE,CAAC;AAEF,eAAe,QAAQ,CAAC"}
|
|
@@ -3,10 +3,10 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
3
3
|
import EditorBase from '../../components/LDEditor/EditorBase.js';
|
|
4
4
|
import { createEditorExtensions } from '../../components/LDEditor/extensions.js';
|
|
5
5
|
import cn from '../../utils/cn.js';
|
|
6
|
-
const LDViewer = ({ content, className }) => {
|
|
6
|
+
const LDViewer = ({ content, className, defaultFontSize }) => {
|
|
7
7
|
return (_jsx(EditorBase.LDViewer, { extensions: createEditorExtensions({
|
|
8
8
|
resizableImage: false,
|
|
9
9
|
resizableTable: false,
|
|
10
|
-
}), content: content, className: cn('prose prose-lg w-full max-w-none', className) }));
|
|
10
|
+
}), content: content, defaultFontSize: defaultFontSize, className: cn('ld-editor-render prose prose-lg w-full max-w-none', className) }));
|
|
11
11
|
};
|
|
12
12
|
export default LDViewer;
|