@loword/loword-design-system 0.7.3 → 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 CHANGED
@@ -127,7 +127,7 @@ pnpm storybook # Storybook 개발 서버 (port 6006)
127
127
  pnpm build # 프로덕션 빌드
128
128
  pnpm check-types # 타입 체크
129
129
  pnpm lint # ESLint (--fix 포함, src + .storybook)
130
- pnpm prettier # Prettier
130
+ pnpm prettier # Prettier (--write, 저장소 전체 — CI 포매팅 검사와 같은 범위)
131
131
  pnpm test # vitest (utils 유닛 + 스토리 브라우저 실행 + a11y)
132
132
  pnpm test:coverage # 커버리지 (utils / components 분리 측정)
133
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,EAMf,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,QAAQ,CAAC,EAAE,SAAS,CAAC;CACtB;AA2CD,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;CACpB;AAgBD,eAAO,MAAM,UAAU;oEAnMpB,SAAS;6BAuCoB,YAAY;wCAWE,YAAY;yFA2BvD,kBAAkB;sDA4BlB,oBAAoB;;mDA4E+B,aAAa;CAmBlE,CAAC;AAEF,eAAe,UAAU,CAAC"}
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 Root = ({ extensions, content = '', onChange, className, children, }) => {
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":"AAwCA,OAAO,EAEL,KAAK,SAAS,EAGf,MAAM,kBAAkB,CAAC;AAqH1B,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,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;AA+UD,QAAA,MAAM,QAAQ,GAAI,mHASf,aAAa,4CAsCf,CAAC;AAimBF,eAAe,QAAQ,CAAC"}
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
- { label: '검정', value: '#111827' },
30
- { label: '빨강', value: '#ef4444' },
31
- { label: '파랑', value: '#3b82f6' },
32
- { label: '초록', value: '#16a34a' },
33
- { label: '주황', value: '#f97316' },
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
- // 이미지를 넣을 본문 폭(px). CSS로 100%를 맞추려면 "크기를 정하지 않은 이미지"를
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 url = urls.get(src);
165
- if (url)
166
- nodes.push(node.type.create({ ...node.attrs, src: url }));
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 urls = new Map(list.map((src, index) => [src, srcs[index] ?? null]));
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 src = srcs[index];
305
- return src
435
+ const image = uploaded[index];
436
+ return image
306
437
  ? [
307
438
  view.state.schema.node('image', {
308
- src,
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: editor.getAttributes('textStyle')?.fontSize || '18px',
366
- color: editor.getAttributes('textStyle')?.color || '#111827',
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
- editor.commands.setImage({
398
- src,
399
- alt: file.name,
400
- width: getContentWidth(editor.view.dom),
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: "gap-2 rounded-t-md border border-neutral200 px-4 py-3", children: [_jsxs(ToolbarGroup, { children: [_jsx(EditorBase.Toolbar.Dropdown, { className: "min-w-30 rounded-lg border border-neutral200 bg-white shadow-lg", trigger: ({ toggle }) => (
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.headingLevel ? `제목 ${state.headingLevel}` : '본문' }) })), children: ({ close }) => HEADINGS.map(({ label, level }) => (_jsx("button", { type: "button", onClick: (e) => {
434
- e.preventDefault();
435
- if (level === 0)
436
- editor.chain().focus().setParagraph().run();
437
- else
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.chain().focus().setFontSize(size).run();
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 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("span", { className: "size-4 rounded-full", style: { backgroundColor: state.color } }), state.color, _jsx(ChevronDown, { className: "size-4" })] })), children: ({ close }) => COLORS.map((color) => (_jsxs("button", { type: "button", onClick: (e) => {
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.chain().focus().setColor(color.value).run();
581
+ setTextStyle(editor, 'caption', state.isCaption);
447
582
  close();
448
- }, 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(LDDivider, {})] }), _jsxs(ToolbarGroup, { children: [['left', 'center', 'right', 'justify'].map((align) => {
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("span", { className: "size-4 rounded-full", style: { backgroundColor: state.color } }), _jsx(ChevronDown, { className: "size-4" })] })), children: ({ close }) => COLORS.map((color) => (_jsxs("button", { type: "button", onClick: (e) => {
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":"AAMA,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,QAAA,MAAM,QAAQ,GAAI,wBAAwB,aAAa,4CAWtD,CAAC;AAEF,eAAe,QAAQ,CAAC"}
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;
@@ -1,4 +1,9 @@
1
1
  import type { Extensions } from '@tiptap/react';
2
+ /**
3
+ * 이미지 너비(img의 width 속성). 숫자나 숫자 문자열(`671`, `'671'`, `'671px'`)은 고정(px), `'100%'`는 꽉 채움,
4
+ * null은 지정 없음(원본 크기, 컨테이너보다 크면 컨테이너 폭).
5
+ */
6
+ export type EditorImageWidth = number | string | null;
2
7
  export interface EditorExtensionOptions {
3
8
  resizableImage?: boolean;
4
9
  resizableTable?: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"extensions.d.ts","sourceRoot":"","sources":["../../../src/components/LDEditor/extensions.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAsChD,MAAM,WAAW,sBAAsB;IACrC,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,eAAO,MAAM,sBAAsB,GAAI,sCAGpC,sBAA2B,KAAG,UA2ChC,CAAC"}
1
+ {"version":3,"file":"extensions.d.ts","sourceRoot":"","sources":["../../../src/components/LDEditor/extensions.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAGhD;;;GAGG;AACH,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;AAyJtD,MAAM,WAAW,sBAAsB;IACrC,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,eAAO,MAAM,sBAAsB,GAAI,sCAGpC,sBAA2B,KAAG,UA0ChC,CAAC"}
@@ -1,9 +1,43 @@
1
- import { mergeAttributes } from '@tiptap/core';
1
+ import { mergeAttributes, ResizableNodeView } from '@tiptap/core';
2
2
  import Image from '@tiptap/extension-image';
3
3
  import { TableKit } from '@tiptap/extension-table';
4
4
  import TextAlign from '@tiptap/extension-text-align';
5
5
  import { Color, FontSize, TextStyle } from '@tiptap/extension-text-style';
6
6
  import StarterKit from '@tiptap/starter-kit';
7
+ // width 속성 값을 편집 화면 너비로 바꾼다. 브라우저가 img의 width 속성을 읽는 방식처럼 앞의 숫자만 보고
8
+ // 뒤가 %면 비율, 아니면 px이다('671'·671·'671px' → 671px, '100%' → 100%). 0 이하나 숫자가 아니면 지정 없음.
9
+ const toWidthStyle = (width) => {
10
+ const match = /^\s*(\d*\.?\d+)\s*(%?)/.exec(String(width ?? ''));
11
+ if (!match || !(Number(match[1]) > 0))
12
+ return undefined;
13
+ return `${Number(match[1])}${match[2] ? '%' : 'px'}`;
14
+ };
15
+ // 리사이즈로 끌어 컨테이너 폭에 이 값(px) 이내로 닿으면 꽉 채움으로 본다.
16
+ const FULL_WIDTH_TOLERANCE = 2;
17
+ /**
18
+ * 편집 화면 img에 노드 속성을 반영한다. 리사이즈 노드뷰는 만들 때만 속성을 읽고 이후 갱신에는
19
+ * DOM을 건드리지 않으므로, 정렬·너비가 바뀔 때마다 직접 맞춰 줘야 한다.
20
+ * 꽉 채움은 래퍼도 100%여야 한다 — 래퍼가 내용 폭(fit-content)이면 img의 100%가 순환해 원본 크기가 된다.
21
+ */
22
+ const syncImage = (view, node) => {
23
+ const img = view.element;
24
+ const attrs = node.attrs;
25
+ if (attrs.src && img.getAttribute('src') !== attrs.src) {
26
+ img.setAttribute('src', attrs.src);
27
+ }
28
+ ['alt', 'title'].forEach((name) => {
29
+ if (attrs[name])
30
+ img.setAttribute(name, attrs[name]);
31
+ else
32
+ img.removeAttribute(name);
33
+ });
34
+ img.style.textAlign = attrs.textAlign ?? '';
35
+ const width = toWidthStyle(attrs.width);
36
+ const percent = width?.endsWith('%');
37
+ view.wrapper.style.width = percent ? width : '';
38
+ img.style.width = percent ? '100%' : (width ?? '');
39
+ img.style.height = '';
40
+ };
7
41
  /**
8
42
  * 링크를 걸 수 있는 이미지. 링크(mark)는 텍스트에만 붙고 블록 이미지는 받지 못하므로
9
43
  * 이미지 노드에 `href`를 두고 저장 시 `<a href><img></a>`로 감싼다.
@@ -13,6 +47,15 @@ const LinkableImage = Image.extend({
13
47
  addAttributes() {
14
48
  return {
15
49
  ...this.parent?.(),
50
+ // 넣기·리사이즈는 고정이면 px 정수, 꽉 채움이면 '100%'를 쓴다. 읽어 들인 값은 문자열 그대로 둬
51
+ // getJSON의 width가 기존과 같은 형태다(0·숫자가 아닌 값만 버린다). 기존 글의 px 너비는 고정으로 읽는다.
52
+ width: {
53
+ default: null,
54
+ parseHTML: (element) => {
55
+ const width = element.getAttribute('width');
56
+ return toWidthStyle(width) ? width : null;
57
+ },
58
+ },
16
59
  href: {
17
60
  default: null,
18
61
  // 붙여넣기·불러오기 때 이미지를 감싼 <a>의 주소를 읽는다
@@ -22,6 +65,68 @@ const LinkableImage = Image.extend({
22
65
  },
23
66
  };
24
67
  },
68
+ addNodeView() {
69
+ if (!this.options.resize || !this.options.resize.enabled)
70
+ return null;
71
+ if (typeof document === 'undefined')
72
+ return null;
73
+ const { directions, minWidth, minHeight, alwaysPreserveAspectRatio } = this.options.resize;
74
+ return ({ node, getPos, HTMLAttributes, editor }) => {
75
+ const el = document.createElement('img');
76
+ el.draggable = false;
77
+ Object.entries(mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)).forEach(([key, value]) => {
78
+ // 너비·높이는 syncImage가 정한다(속성이 아니라 스타일로)
79
+ if (value != null && key !== 'width' && key !== 'height') {
80
+ el.setAttribute(key, value);
81
+ }
82
+ });
83
+ const view = new ResizableNodeView({
84
+ element: el,
85
+ editor,
86
+ node,
87
+ getPos,
88
+ // 끄는 동안 img가 px 너비로 바뀌므로 꽉 채움 래퍼를 풀어 핸들이 모서리에 붙게 한다
89
+ onResize: () => {
90
+ view.wrapper.style.width = '';
91
+ },
92
+ // 내용 폭에 닿으면 꽉 채움, 아니면 그 px(고정). 높이는 저장하지 않고 항상 비율을 따른다.
93
+ onCommit: (width) => {
94
+ const pos = getPos();
95
+ if (pos === undefined)
96
+ return;
97
+ const full = Math.abs(width - view.dom.clientWidth) <= FULL_WIDTH_TOLERANCE;
98
+ editor
99
+ .chain()
100
+ .setNodeSelection(pos)
101
+ .updateAttributes(this.name, {
102
+ width: full ? '100%' : Math.round(width),
103
+ height: null,
104
+ })
105
+ .run();
106
+ },
107
+ onUpdate: (updatedNode) => {
108
+ if (updatedNode.type !== node.type)
109
+ return false;
110
+ syncImage(view, updatedNode);
111
+ return true;
112
+ },
113
+ options: {
114
+ directions,
115
+ min: { width: minWidth, height: minHeight },
116
+ preserveAspectRatio: alwaysPreserveAspectRatio === true,
117
+ },
118
+ });
119
+ syncImage(view, node);
120
+ // 로드 전에는 숨겨 크기가 튀어 보이지 않게 한다. 깨진 이미지도 보이고 선택돼야 지울 수 있다.
121
+ view.dom.style.visibility = 'hidden';
122
+ view.dom.style.pointerEvents = 'none';
123
+ el.onload = el.onerror = () => {
124
+ view.dom.style.visibility = '';
125
+ view.dom.style.pointerEvents = '';
126
+ };
127
+ return view;
128
+ };
129
+ },
25
130
  renderHTML({ node, HTMLAttributes }) {
26
131
  const img = [
27
132
  'img',
@@ -49,12 +154,11 @@ export const createEditorExtensions = ({ resizableImage = true, resizableTable =
49
154
  link: {
50
155
  openOnClick: false,
51
156
  autolink: true,
52
- HTMLAttributes: {
53
- class: 'text-blue-500 underline cursor-pointer',
54
- },
55
157
  },
56
158
  }),
57
- TextStyle,
159
+ // 글자 색·크기 mark가 링크 mark(priority 1000)보다 바깥이어야 한다. 링크가 바깥이면 밑줄이 링크 요소의
160
+ // 색으로 그려져 안쪽 글자 색을 따르지 못한다.
161
+ TextStyle.extend({ priority: 1001 }),
58
162
  FontSize,
59
163
  Color,
60
164
  TextAlign.configure({
package/dist/index.css CHANGED
@@ -1278,6 +1278,9 @@
1278
1278
  .ml-2 {
1279
1279
  margin-left: calc(var(--spacing) * 2);
1280
1280
  }
1281
+ .ml-\[17px\] {
1282
+ margin-left: 17px;
1283
+ }
1281
1284
  .ml-auto {
1282
1285
  margin-left: auto;
1283
1286
  }
@@ -1682,6 +1685,9 @@
1682
1685
  .min-h-25 {
1683
1686
  min-height: calc(var(--spacing) * 25);
1684
1687
  }
1688
+ .min-h-40 {
1689
+ min-height: calc(var(--spacing) * 40);
1690
+ }
1685
1691
  .min-h-100 {
1686
1692
  min-height: calc(var(--spacing) * 100);
1687
1693
  }
@@ -1818,6 +1824,9 @@
1818
1824
  .w-\[60px\] {
1819
1825
  width: 60px;
1820
1826
  }
1827
+ .w-\[calc\(100\%-34px\)\] {
1828
+ width: calc(100% - 34px);
1829
+ }
1821
1830
  .w-auto {
1822
1831
  width: auto;
1823
1832
  }
@@ -1836,6 +1845,9 @@
1836
1845
  .max-w-160 {
1837
1846
  max-width: calc(var(--spacing) * 160);
1838
1847
  }
1848
+ .max-w-180 {
1849
+ max-width: calc(var(--spacing) * 180);
1850
+ }
1839
1851
  .max-w-200 {
1840
1852
  max-width: calc(var(--spacing) * 200);
1841
1853
  }
@@ -2022,6 +2034,9 @@
2022
2034
  .resize {
2023
2035
  resize: both;
2024
2036
  }
2037
+ .resize-y {
2038
+ resize: vertical;
2039
+ }
2025
2040
  .grid-cols-1 {
2026
2041
  grid-template-columns: repeat(1, minmax(0, 1fr));
2027
2042
  }
@@ -2420,6 +2435,9 @@
2420
2435
  .border-neutral200 {
2421
2436
  border-color: var(--color-neutral200);
2422
2437
  }
2438
+ .border-neutral300 {
2439
+ border-color: var(--color-neutral300);
2440
+ }
2423
2441
  .border-neutral700 {
2424
2442
  border-color: var(--color-neutral700);
2425
2443
  }
@@ -3502,6 +3520,9 @@
3502
3520
  .\[outline-style\:solid\] {
3503
3521
  outline-style: solid;
3504
3522
  }
3523
+ .\[scrollbar-gutter\:stable\] {
3524
+ scrollbar-gutter: stable;
3525
+ }
3505
3526
  .\[scrollbar-width\:none\] {
3506
3527
  scrollbar-width: none;
3507
3528
  }
@@ -5563,11 +5584,37 @@
5563
5584
  padding: 0 !important;
5564
5585
  line-height: calc(28 / 18) !important;
5565
5586
  }
5587
+ .ld-editor-render .ProseMirror p:empty::before, .ProseMirror.ld-editor-render p:empty::before {
5588
+ content: '\200B';
5589
+ }
5590
+ .ld-editor-render .ProseMirror a, .ProseMirror.ld-editor-render a {
5591
+ color: inherit !important;
5592
+ text-decoration-color: currentcolor !important;
5593
+ }
5594
+ .ld-editor-render .ProseMirror :is(a, u), .ProseMirror.ld-editor-render :is(a, u) {
5595
+ text-underline-offset: 2px;
5596
+ }
5597
+ .ld-editor-render.prose, .ld-editor-render .prose {
5598
+ --tw-prose-body: var(--color-neutral900);
5599
+ letter-spacing: var(--tracking-display16);
5600
+ }
5566
5601
  .prose h1, .prose h2, .prose h3 {
5567
- margin-top: 0 !important;
5568
- margin-bottom: 0.5rem !important;
5602
+ margin-top: 1em !important;
5603
+ margin-bottom: 1em !important;
5569
5604
  padding: 0 !important;
5570
5605
  }
5606
+ .prose :is(h1, h2, h3):first-child {
5607
+ margin-top: 0 !important;
5608
+ }
5609
+ .prose h1 {
5610
+ line-height: calc(56 / 48) !important;
5611
+ }
5612
+ .prose h2 {
5613
+ line-height: calc(36 / 30) !important;
5614
+ }
5615
+ .prose h3 {
5616
+ line-height: calc(28 / 24) !important;
5617
+ }
5571
5618
  .prose ul, .prose ol {
5572
5619
  margin-top: 0 !important;
5573
5620
  margin-bottom: 0.5rem !important;
@@ -5586,6 +5633,16 @@
5586
5633
  margin-bottom: 0;
5587
5634
  }
5588
5635
  }
5636
+ @layer components {
5637
+ .ld-editor-render .ProseMirror, .ProseMirror.ld-editor-render {
5638
+ white-space: pre-wrap;
5639
+ white-space: break-spaces;
5640
+ }
5641
+ .ld-editor-render .ProseMirror:not([contenteditable='true']), .ProseMirror.ld-editor-render:not([contenteditable='true']) {
5642
+ overflow-y: auto;
5643
+ scrollbar-gutter: stable;
5644
+ }
5645
+ }
5589
5646
  .ProseMirror img {
5590
5647
  max-width: 100%;
5591
5648
  height: auto !important;
@@ -5676,6 +5733,16 @@
5676
5733
  }
5677
5734
  .ProseMirror [data-resize-wrapper] {
5678
5735
  width: fit-content;
5736
+ max-width: 100%;
5737
+ }
5738
+ .ProseMirror [data-resize-container] {
5739
+ justify-content: center;
5740
+ }
5741
+ .ProseMirror [data-resize-container]:has(img[style*='text-align: left']) {
5742
+ justify-content: flex-start;
5743
+ }
5744
+ .ProseMirror [data-resize-container]:has(img[style*='text-align: right']) {
5745
+ justify-content: flex-end;
5679
5746
  }
5680
5747
  .ProseMirror .tableWrapper {
5681
5748
  overflow-x: auto;
package/dist/index.d.ts CHANGED
@@ -23,7 +23,7 @@ export { default as LDFormDateRangePicker } from './components/LDDateRangePicker
23
23
  export { type LDDaumPostAddress, default as LDDaumPostButton, type LDDaumPostButtonProps, } from './components/LDDaumPostButton/LDDaumPostButton.js';
24
24
  export { default as LDDivider, type LDDividerProps, } from './components/LDDivider/LDDivider.js';
25
25
  export { type DrawerButton, default as LDDrawer, type LDDrawerBodyProps, type LDDrawerCloseProps, type LDDrawerContentProps, type LDDrawerDescriptionProps, type LDDrawerFooterProps, type LDDrawerHandleProps, type LDDrawerHeaderProps, type LDDrawerOverlayProps, type LDDrawerPanelProps, type LDDrawerProps, type LDDrawerRootProps, type LDDrawerTitleProps, type LDDrawerTriggerProps, } from './components/LDDrawer/LDDrawer.js';
26
- export { default as LDEditor } from './components/LDEditor/LDEditor.js';
26
+ export { default as LDEditor, type LDEditorFontSize, } from './components/LDEditor/LDEditor.js';
27
27
  export { default as LDViewer } from './components/LDEditor/LDViewer.js';
28
28
  export { type EmptySize, default as LDEmpty, type LDEmptyProps, } from './components/LDEmpty/LDEmpty.js';
29
29
  export { type FileUploadAttachment as LDFileUploadAttachment, type FileUploadOptions as LDFileUploadOptions, } from './components/LDFileUpload/FileUploadBase.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -43,7 +43,7 @@ export { default as LDDivider, } from './components/LDDivider/LDDivider.js';
43
43
  // LDDrawer
44
44
  export { default as LDDrawer, } from './components/LDDrawer/LDDrawer.js';
45
45
  // LDEditor
46
- export { default as LDEditor } from './components/LDEditor/LDEditor.js';
46
+ export { default as LDEditor, } from './components/LDEditor/LDEditor.js';
47
47
  export { default as LDViewer } from './components/LDEditor/LDViewer.js';
48
48
  // LDEmpty
49
49
  export { default as LDEmpty, } from './components/LDEmpty/LDEmpty.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loword/loword-design-system",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
4
4
  "description": "로워드 디자인 시스템 컴포넌트 패키지(Tailwind v4)",
5
5
  "keywords": [
6
6
  "react",
@@ -241,7 +241,6 @@
241
241
  "eslint-plugin-simple-import-sort": "^14.0.0",
242
242
  "eslint-plugin-storybook": "10.5.7",
243
243
  "globals": "^16.3.0",
244
- "lint-staged": "^17.3.0",
245
244
  "next": "15.5.18",
246
245
  "playwright": "latest",
247
246
  "prettier": "^3.9.6",
@@ -282,7 +281,7 @@
282
281
  "dev:styles": "tailwindcss -i ./src/styles.css -o ./dist/index.css --watch",
283
282
  "lint": "eslint --fix src .storybook --max-warnings 0",
284
283
  "lint:ci": "eslint src .storybook --max-warnings 0",
285
- "prettier": "prettier --write src .storybook --log-level warn",
284
+ "prettier": "prettier --write . --log-level warn",
286
285
  "test": "vitest run",
287
286
  "test:watch": "vitest",
288
287
  "test:coverage": "pnpm run test:coverage:utils && pnpm run test:coverage:components",
@@ -11,14 +11,70 @@
11
11
  line-height: calc(28 / 18) !important;
12
12
  }
13
13
 
14
+ /* 정적 렌더 옵트인(.ld-editor-render, 렌더 루트나 그 조상에 붙인다). 클래스가 없으면 기존 렌더 그대로다.
15
+ getHTML()은 빈 문단을 <p></p>로 내보낸다. 편집 화면은 <p><br class="ProseMirror-trailingBreak"></p>라
16
+ 한 줄 높이인데, 내용 없는 <p>는 높이 0이라 margin이 겹쳐 사라진다. 제로폭 문자로 한 줄 높이를 만든다.
17
+ :empty는 자식 노드가 없을 때만 맞으므로 <br>이 있는 편집 화면에는 영향이 없다. */
18
+ .ld-editor-render .ProseMirror p:empty::before,
19
+ .ProseMirror.ld-editor-render p:empty::before {
20
+ content: '\200B';
21
+ }
22
+
23
+ /* 링크 색(.ld-editor-render 옵트인, 편집 영역에도 붙는다): 링크 글자색은 주변 글자 색(색 mark 포함)을 따르고
24
+ 밑줄도 그 글자색으로 그린다. 예전에 저장된 글의 링크에 박힌 파란색 클래스(text-blue-500)도 덮으려고 !important.
25
+ 글자 색 mark가 링크 요소 바깥에 있어야 밑줄이 그 색을 따른다(extensions.ts의 TextStyle priority). */
26
+ .ld-editor-render .ProseMirror a,
27
+ .ProseMirror.ld-editor-render a {
28
+ color: inherit !important;
29
+ text-decoration-color: currentcolor !important;
30
+ }
31
+
32
+ /* 밑줄(링크·밑줄 서식)이 글자에 붙어 보이지 않게 2px 띄운다. 편집 영역·LDViewer·정적 렌더가 같은 값을 받는다. */
33
+ .ld-editor-render .ProseMirror :is(a, u),
34
+ .ProseMirror.ld-editor-render :is(a, u) {
35
+ text-underline-offset: 2px;
36
+ }
37
+
38
+ /* 글 기본 색(.ld-editor-render 옵트인, 편집 영역에도 붙는다): 툴바 글자 색 목록의 '검정'(neutral900)이다.
39
+ 색 mark가 없는 글이 이 색으로 보이므로, 목록에서 '검정'을 고르면 mark를 걷어 낸다. */
40
+ .ld-editor-render.prose,
41
+ .ld-editor-render .prose {
42
+ --tw-prose-body: var(--color-neutral900);
43
+ /* 자간: LDS 타이포(display10~30) 토큰과 같은 -1px. 편집 영역·LDViewer·정적 렌더가 같은 값을 받아
44
+ 줄바꿈 위치가 같다. 글자 크기와 상관없이 고정 px(시안 letterSpacing -1). */
45
+ letter-spacing: var(--tracking-display16);
46
+ }
47
+
48
+ /* 제목 위아래 여백은 제목 자기 글자 크기만큼(1em) 둔다. 제목이 클수록 앞뒤 본문과 더 벌어진다.
49
+ 앞 문단의 아래 여백(0.8em)과는 겹쳐(margin collapse) 둘 중 큰 값만 남는다. */
14
50
  .prose h1,
15
51
  .prose h2,
16
52
  .prose h3 {
17
- margin-top: 0 !important;
18
- margin-bottom: 0.5rem !important;
53
+ margin-top: 1em !important;
54
+ margin-bottom: 1em !important;
19
55
  padding: 0 !important;
20
56
  }
21
57
 
58
+ /* 글 첫머리 제목은 위 여백 없이 시작한다(아래 .ProseMirror :first-child는 !important가 아니라 위 규칙에 진다). */
59
+ .prose :is(h1, h2, h3):first-child {
60
+ margin-top: 0 !important;
61
+ }
62
+
63
+ /* 제목 줄 높이는 같은 글자 크기의 display 토큰(typography.css)을 따른다. 제목 크기는 본문 기준 em이라
64
+ (본문 18px일 때 48·30·24px) px 대신 토큰의 비율로 둬 defaultFontSize가 바뀌어도 같은 비율이다.
65
+ 제목 1(48px)은 같은 크기 토큰이 없어 display40·display64처럼 글자 크기 + 8px로 둔다. */
66
+ .prose h1 {
67
+ line-height: calc(56 / 48) !important;
68
+ }
69
+
70
+ .prose h2 {
71
+ line-height: calc(36 / 30) !important; /* display30 */
72
+ }
73
+
74
+ .prose h3 {
75
+ line-height: calc(28 / 24) !important; /* display24 */
76
+ }
77
+
22
78
  .prose ul,
23
79
  .prose ol {
24
80
  margin-top: 0 !important;
@@ -52,6 +108,27 @@
52
108
  }
53
109
  }
54
110
 
111
+ /* 정적 렌더 옵트인(.ld-editor-render): 편집 화면과 같이 연속 공백·줄 구분 문자(U+2028 등)를 보존한다.
112
+ tiptap은 편집 가능한 에디터에만 같은 규칙을 주입한다. 클래스가 없는 정적 렌더는 기존 그대로다.
113
+ 앞 줄은 break-spaces 미지원 브라우저용 폴백. */
114
+ @layer components {
115
+ .ld-editor-render .ProseMirror,
116
+ .ProseMirror.ld-editor-render {
117
+ white-space: pre-wrap;
118
+ white-space: break-spaces;
119
+ }
120
+
121
+ /* 편집 영역은 고정 높이 + 내부 스크롤이라 스크롤바(classic 환경 15px)가 내용 폭을 줄인다.
122
+ 편집 요소는 scrollbar-gutter: stable로 자리를 항상 확보하므로(LDEditor), 정적 렌더·LDViewer도
123
+ 같은 거터를 확보해 줄바꿈이 같게 한다. 높이는 auto라 실제 스크롤은 생기지 않는다.
124
+ 편집 요소(contenteditable="true")에도 ld-editor-render가 붙어 있어 제외한다. */
125
+ .ld-editor-render .ProseMirror:not([contenteditable='true']),
126
+ .ProseMirror.ld-editor-render:not([contenteditable='true']) {
127
+ overflow-y: auto;
128
+ scrollbar-gutter: stable;
129
+ }
130
+ }
131
+
55
132
  /* 이미지 정렬 스타일 */
56
133
  /* height만 !important로 잠근다. 리사이즈 노드뷰가 width/height를 함께 인라인 px로
57
134
  쓰는데, 높이가 고정되면 컨테이너가 좁아질 때 max-width로 폭만 줄어 이미지가
@@ -180,6 +257,21 @@
180
257
  (래퍼는 부모가 block이면 기본값이 100%라 가만두면 벌어진다.) */
181
258
  .ProseMirror [data-resize-wrapper] {
182
259
  width: fit-content;
260
+ max-width: 100%;
261
+ }
262
+
263
+ /* 편집 화면의 리사이즈 노드뷰는 flex 컨테이너라 이미지가 왼쪽에 붙는다. 정적 렌더(위 img 규칙)와
264
+ 같게 기본은 가운데, text-align이 지정된 이미지는 그쪽으로 둔다. */
265
+ .ProseMirror [data-resize-container] {
266
+ justify-content: center;
267
+ }
268
+
269
+ .ProseMirror [data-resize-container]:has(img[style*='text-align: left']) {
270
+ justify-content: flex-start;
271
+ }
272
+
273
+ .ProseMirror [data-resize-container]:has(img[style*='text-align: right']) {
274
+ justify-content: flex-end;
183
275
  }
184
276
 
185
277
  /* 표