@team-monolith/cds 1.121.2 → 1.122.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,12 +13,15 @@ import { ImageNotAvailable } from "../../nodes/ImageNode/ImageNotAvailable";
13
13
  import styled from "@emotion/styled";
14
14
  import { debounce } from "lodash";
15
15
  import { useTranslation } from "react-i18next";
16
+ import { getTexts } from "../../../../texts";
17
+ import { isDataUrl } from "../../utils/url";
16
18
  export function InsertImageDialog(props) {
17
19
  const { title, open, onClose, imageProps, onChange, onDelete, shouldReset } = props;
18
20
  const theme = useTheme();
19
21
  const { t } = useTranslation();
20
- const { control, setValue, watch, reset, handleSubmit, subscribe } = useForm({
22
+ const { control, setValue, watch, reset, handleSubmit, subscribe, formState: { errors }, } = useForm({
21
23
  defaultValues: imageProps !== null && imageProps !== void 0 ? imageProps : { src: "", altText: "" },
24
+ mode: "all",
22
25
  });
23
26
  const handleOnClose = () => {
24
27
  if (shouldReset) {
@@ -33,7 +36,7 @@ export function InsertImageDialog(props) {
33
36
  // src는 입력 필드에 실시간으로 반영되는 값이고 previewSrc는 입력이 끝난 뒤 미리보기 영역에 표시할 URL입니다.
34
37
  // 두 값은 시멘틱이 다르므로 독립적인 state로 관리합니다.
35
38
  const [previewSrc, setPreviewSrc] = useState(watch("src"));
36
- const isDisabled = watch("src") === "";
39
+ const isDisabled = watch("src") === "" || Boolean(errors.src);
37
40
  const debouncedSetPreviewSrc = useMemo(() => debounce((value) => setPreviewSrc(value), 500), []);
38
41
  useEffect(() => {
39
42
  const unsubscribe = subscribe({
@@ -50,8 +53,15 @@ export function InsertImageDialog(props) {
50
53
  e.stopPropagation();
51
54
  handleSubmit(onSubmit)();
52
55
  }, disableIconPadding: true, children: [_jsx(StyledAlertDialogTitle, { onClose: handleOnClose, children: title }), _jsx(AlertDialogContent, { children: _jsxs(Inputs, { children: [_jsx(FileSelectInput, { onChange: (value) => {
53
- setValue("src", value);
54
- }, fileType: "image" }), _jsx(FormInput, { name: "src", control: control, placeholder: "https://www.pexels.com/photo/n-2848492", size: "medium", label: "URL", fullWidth: true, startIcon: _jsx(LinkIcon, {}) }), previewSrc && (_jsx(ImagePreview, { src: previewSrc, alt: watch("altText"), fallback: _jsx(ImageNotAvailable, {}) })), _jsx(FormInput, { name: "altText", control: control, placeholder: t("삽입하는 이미지에 관한 설명"), size: "medium", label: t("대체 텍스트"), fullWidth: true })] }) }), _jsxs(AlertDialogActions, { children: [_jsx(Button, { type: "submit", fullWidth: true, label: t("삽입하기"), size: "medium", color: "primary", disabled: isDisabled }), onDelete && (_jsx(Button, { color: "danger", size: "medium", fullWidth: true, label: t("삭제하기"), onClick: onDelete }))] })] }));
56
+ setValue("src", value, {
57
+ shouldDirty: true,
58
+ shouldValidate: true,
59
+ });
60
+ }, fileType: "image" }), _jsx(FormInput, { name: "src", control: control, placeholder: "https://www.pexels.com/photo/n-2848492", size: "medium", label: "URL", fullWidth: true, startIcon: _jsx(LinkIcon, {}), rules: {
61
+ validate: (value) => value === "" || !isDataUrl(value)
62
+ ? true
63
+ : getTexts(t, "errorImageDataUrlNotAllowed"),
64
+ } }), previewSrc && (_jsx(ImagePreview, { src: previewSrc, alt: watch("altText"), fallback: _jsx(ImageNotAvailable, {}) })), _jsx(FormInput, { name: "altText", control: control, placeholder: t("삽입하는 이미지에 관한 설명"), size: "medium", label: t("대체 텍스트"), fullWidth: true })] }) }), _jsxs(AlertDialogActions, { children: [_jsx(Button, { type: "submit", fullWidth: true, label: t("삽입하기"), size: "medium", color: "primary", disabled: isDisabled }), onDelete && (_jsx(Button, { color: "danger", size: "medium", fullWidth: true, label: t("삭제하기"), onClick: onDelete }))] })] }));
55
65
  }
56
66
  const StyledAlertDialog = styled(AlertDialog) `
57
67
  gap: 16px;
@@ -1,3 +1,12 @@
1
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
+ return new (P || (P = Promise))(function (resolve, reject) {
4
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
8
+ });
9
+ };
1
10
  /**
2
11
  * Copyright (c) Meta Platforms, Inc. and affiliates.
3
12
  *
@@ -7,21 +16,133 @@
7
16
  */
8
17
  import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
9
18
  import { $insertNodeToNearestRoot, mergeRegister } from "@lexical/utils";
10
- import { $createRangeSelection, $getSelection, $isNodeSelection, $setSelection, COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW, createCommand, DRAGOVER_COMMAND, DRAGSTART_COMMAND, DROP_COMMAND, } from "lexical";
11
- import { useEffect } from "react";
19
+ import { $createRangeSelection, $getNodeByKey, $getSelection, $isNodeSelection, $setSelection, COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW, createCommand, DRAGOVER_COMMAND, DRAGSTART_COMMAND, DROP_COMMAND, } from "lexical";
20
+ import { useContext, useEffect, useRef } from "react";
21
+ import { useTranslation } from "react-i18next";
12
22
  import { $createImageNode, $isImageNode, ImageNode, } from "../../nodes";
23
+ import { CdsContext } from "../../../../CdsProvider";
24
+ import { getTexts } from "../../../../texts";
25
+ import { isDataUrl } from "../../utils/url";
13
26
  const CAN_USE_DOM = typeof window !== "undefined" &&
14
27
  typeof window.document !== "undefined" &&
15
28
  typeof window.document.createElement !== "undefined";
16
29
  const getDOMSelection = (targetWindow) => CAN_USE_DOM ? (targetWindow || window).getSelection() : null;
30
+ function parseBase64Image(dataUrl) {
31
+ var _a;
32
+ const commaIndex = dataUrl.indexOf(",");
33
+ if (commaIndex < 0) {
34
+ return null;
35
+ }
36
+ const metadata = dataUrl.slice("data:".length, commaIndex);
37
+ const base64 = dataUrl.slice(commaIndex + 1).trim();
38
+ const metadataParts = metadata.split(";");
39
+ const mimeType = (_a = metadataParts[0]) !== null && _a !== void 0 ? _a : "";
40
+ const isBase64Encoded = metadataParts.includes("base64");
41
+ if (!isBase64Encoded || !mimeType.startsWith("image/") || base64 === "") {
42
+ return null;
43
+ }
44
+ return { base64, mimeType };
45
+ }
46
+ function createFileFromParsedDataUrl(parsed) {
47
+ if (!CAN_USE_DOM || typeof File === "undefined" || !("atob" in window)) {
48
+ return null;
49
+ }
50
+ try {
51
+ const binaryString = window.atob(parsed.base64.replace(/\s/g, ""));
52
+ const length = binaryString.length;
53
+ const bytes = new Uint8Array(length);
54
+ for (let index = 0; index < length; index += 1) {
55
+ bytes[index] = binaryString.charCodeAt(index);
56
+ }
57
+ const extension = getExtensionFromMimeType(parsed.mimeType);
58
+ const filename = `pasted-image-${Date.now()}.${extension}`;
59
+ return new File([bytes], filename, { type: parsed.mimeType });
60
+ }
61
+ catch (_a) {
62
+ return null;
63
+ }
64
+ }
65
+ function getExtensionFromMimeType(mimeType) {
66
+ const match = mimeType.match(/\/([a-zA-Z0-9.+-]+)/);
67
+ if (!match || match.length < 2) {
68
+ return "png";
69
+ }
70
+ const subtype = match[1];
71
+ return subtype.includes("+") ? subtype.split("+")[0] : subtype;
72
+ }
17
73
  export const INSERT_IMAGE_COMMAND = createCommand("INSERT_IMAGE_COMMAND");
18
74
  export function ImagesPlugin({ captionsEnabled, }) {
75
+ var _a, _b;
19
76
  const [editor] = useLexicalComposerContext();
77
+ const cdsContext = useContext(CdsContext);
78
+ const uploadByFile = (_a = cdsContext.lexical) === null || _a === void 0 ? void 0 : _a.uploadByFile;
79
+ const showFileError = (_b = cdsContext.lexical) === null || _b === void 0 ? void 0 : _b.showFileError;
80
+ const { t } = useTranslation();
81
+ const processingNodesRef = useRef(new Set());
20
82
  useEffect(() => {
21
83
  if (!editor.hasNodes([ImageNode])) {
22
84
  throw new Error("ImagesPlugin: ImageNode not registered on editor");
23
85
  }
24
- return mergeRegister(editor.registerCommand(INSERT_IMAGE_COMMAND, (payload) => {
86
+ return mergeRegister(editor.registerNodeTransform(ImageNode, (node) => {
87
+ const key = node.getKey();
88
+ const src = node.getSrc();
89
+ if (!isDataUrl(src)) {
90
+ processingNodesRef.current.delete(key);
91
+ return;
92
+ }
93
+ if (processingNodesRef.current.has(key)) {
94
+ return;
95
+ }
96
+ processingNodesRef.current.add(key);
97
+ const parsedDataUrl = parseBase64Image(src);
98
+ if (!parsedDataUrl) {
99
+ processingNodesRef.current.delete(key);
100
+ node.remove();
101
+ showFileError === null || showFileError === void 0 ? void 0 : showFileError("upload", getTexts(t, "errorImageDataUrlNotAllowed"));
102
+ return;
103
+ }
104
+ if (!uploadByFile) {
105
+ processingNodesRef.current.delete(key);
106
+ node.remove();
107
+ showFileError === null || showFileError === void 0 ? void 0 : showFileError("upload", getTexts(t, "errorImageDataUrlNotAllowed"));
108
+ return;
109
+ }
110
+ void (() => __awaiter(this, void 0, void 0, function* () {
111
+ const file = createFileFromParsedDataUrl(parsedDataUrl);
112
+ if (!file) {
113
+ processingNodesRef.current.delete(key);
114
+ showFileError === null || showFileError === void 0 ? void 0 : showFileError("upload", getTexts(t, "errorImageDataUrlNotAllowed"));
115
+ editor.update(() => {
116
+ const currentNode = $getNodeByKey(key);
117
+ if ($isImageNode(currentNode)) {
118
+ currentNode.remove();
119
+ }
120
+ });
121
+ return;
122
+ }
123
+ try {
124
+ const uploadedUrl = yield uploadByFile(file);
125
+ editor.update(() => {
126
+ const currentNode = $getNodeByKey(key);
127
+ if ($isImageNode(currentNode)) {
128
+ currentNode.setSrc(uploadedUrl);
129
+ }
130
+ });
131
+ }
132
+ catch (_a) {
133
+ showFileError === null || showFileError === void 0 ? void 0 : showFileError("upload", getTexts(t, "errorImageBase64UploadFailed"));
134
+ editor.update(() => {
135
+ const currentNode = $getNodeByKey(key);
136
+ if ($isImageNode(currentNode)) {
137
+ currentNode.remove();
138
+ }
139
+ });
140
+ }
141
+ finally {
142
+ processingNodesRef.current.delete(key);
143
+ }
144
+ }))();
145
+ }), editor.registerCommand(INSERT_IMAGE_COMMAND, (payload) => {
25
146
  const imageNode = $createImageNode(payload);
26
147
  // lexical의 원본코드에서는 이미지 노드를 텍스트 노드 안에 삽입하고 있었습니다.
27
148
  // 이 때문에 이미지 노드 아래에 불필요한 텍스트 라인이 생성됩니다.
@@ -31,7 +152,7 @@ export function ImagesPlugin({ captionsEnabled, }) {
31
152
  $insertNodeToNearestRoot(imageNode);
32
153
  return true;
33
154
  }, COMMAND_PRIORITY_EDITOR), editor.registerCommand(DRAGSTART_COMMAND, (event) => onDragStart(event), COMMAND_PRIORITY_HIGH), editor.registerCommand(DRAGOVER_COMMAND, (event) => onDragover(event), COMMAND_PRIORITY_LOW), editor.registerCommand(DROP_COMMAND, (event) => onDrop(event, editor), COMMAND_PRIORITY_HIGH));
34
- }, [captionsEnabled, editor]);
155
+ }, [captionsEnabled, editor, showFileError, t, uploadByFile]);
35
156
  return null;
36
157
  }
37
158
  const TRANSPARENT_IMAGE = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
@@ -6,4 +6,5 @@
6
6
  *
7
7
  */
8
8
  export declare function sanitizeUrl(url: string): string;
9
+ export declare function isDataUrl(url: string): boolean;
9
10
  export declare function validateUrl(url: string): boolean;
@@ -12,6 +12,7 @@ const SUPPORTED_URL_PROTOCOLS = new Set([
12
12
  "sms:",
13
13
  "tel:",
14
14
  ]);
15
+ const DATA_URL_PATTERN = /^data:[^,]+,/i;
15
16
  export function sanitizeUrl(url) {
16
17
  try {
17
18
  const parsedUrl = new URL(url);
@@ -25,6 +26,9 @@ export function sanitizeUrl(url) {
25
26
  }
26
27
  return url;
27
28
  }
29
+ export function isDataUrl(url) {
30
+ return DATA_URL_PATTERN.test(url);
31
+ }
28
32
  // Source: https://stackoverflow.com/a/8234912/2013580
29
33
  const urlRegExp = new RegExp(/((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-_]*)?\??(?:[-+=&;%@.\w_]*)#?(?:[\w]*))?)/);
30
34
  export function validateUrl(url) {
package/dist/texts.d.ts CHANGED
@@ -2,6 +2,8 @@ import { TFunction } from "i18next";
2
2
  type TranslationMap = {
3
3
  placeholderEnterHere: string;
4
4
  errorFileTooLarge: string;
5
+ errorImageDataUrlNotAllowed: string;
6
+ errorImageBase64UploadFailed: string;
5
7
  descriptionDefaultInputText: string;
6
8
  exampleEnterHere: string;
7
9
  placeholderEvaluationItem: string;
package/dist/texts.js CHANGED
@@ -1,6 +1,8 @@
1
1
  const TRANSLATION_TEXT = {
2
2
  placeholderEnterHere: (t) => t("여기에 입력하세요."),
3
3
  errorFileTooLarge: (t) => t("용량이 너무 큽니다. 1GB 이하의 파일을 선택해 주세요."),
4
+ errorImageDataUrlNotAllowed: (t) => t("데이터 URL 기반 이미지는 지원되지 않습니다. 파일 업로드를 사용해 주세요."),
5
+ errorImageBase64UploadFailed: (t) => t("이미지를 업로드하지 못했습니다. 다시 시도해 주세요."),
4
6
  descriptionDefaultInputText: (t) => t("입력 칸에 기본으로 노출되는 텍스트입니다."),
5
7
  exampleEnterHere: (t) => t("예) 여기에 입력하세요."),
6
8
  placeholderEvaluationItem: (t) => t("평가 항목을 입력하세요."),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-monolith/cds",
3
- "version": "1.121.2",
3
+ "version": "1.122.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "sideEffects": false,