[ckeditor5] ImageBlock & ImageInlie data attribute 추가 및 이미지 업로드 기능 커스텀하기 (feat. Next.js)

seung·2023년 4월 27일
0

Next.js 에서 ckeditor5 를 적용하면서 Image Upload 기능에서 삽질을 많이 했다.
특히, img 태그에 data-id 같은 커스텀 속성을 추가하는 기능을 구현해야 했는데 그 레퍼런스를 찾는 과정에서 많은 어려움이 있었다...
개인 기록용과 더불어서 나와 같이 ckeditor5 의 img 태그 내 커스텀 attribute 추가 기능 관련해서 삽질을 하시는 분이 있으면 도움이 되고자 대충이라도 정리해놓는다..!

Next.js 에 ckeditor5 적용

next 에 적용하는 방법은 아래 블로그를 참고했다.

에러 발생 내용과 과정이 자세하게 나와있어서 보다 편하게 적용했다!

참고 : Next.js에서 CKEditor5 사용하기


이미지 업로드 커스텀

ckeditor 에서 이미지를 다루다보면 서버에 업로드해서 해당 이미지 주소를 받아와 img 태그의 src 속성으로 넣어주는 과정이 필요할 때가 있다.

그런 경우에는 ckeditor의 config 에 추가적인 plugin 을 만들어서 전달해주면 된다.

일단, 이미지 업로드 커스텀 플러그인 함수를 만든다.

  const uploadAdapter = (loader) => {
    return {
      upload: () => {
        return new Promise((resolve, reject) => {
          loader.file.then((file) => {
            // 만약 서버에 이미지 올리고 받는 등 추가 작업 있을 시 추가해서 이미지 주소를 가져온다.
            resolve({
              default: '여기에 이미지 주소 넣으면 됩니다.',
            });
          });
        });
      },
    };
  };
  const uploadPlugin = (editor) => {
    editor.plugins.get('FileRepository').createUploadAdapter = (
      loader: any
    ) => {
      return uploadAdapter(loader);
    };
  };

만든 이미지 업로드 커스텀 플러그인 함수를 ckeditor 컴포넌트 config 속성의 extraPlugins에 추가한다.

<CKEditor
  editor={Editor}
  data=""
  config={{
    extraPlugins: [uploadPlugin],
  }}
  />

이미지 업로드 시 img 태그에 data attribute 추가하기

개발을 하다보니 백엔드에 텍스트에디터의 content를 보내줄 때 img 태그 내에 data-id 속성을 추가해서 보내줘야 하는 과정이 있었다.

처음 생각에는 위의 이미지 업로드 커스텀 함수 내부에서 아래와 같이 속성만 추가해서 보내면 되겠지 라고 생각했지만, 실제 img 태그에는 data-id 속성이 추가되지 않았다.

  const uploadAdapter = (loader) => {
    return {
      upload: () => {
        return new Promise((resolve, reject) => {
          loader.file.then((file) => {
            // 만약 서버에 이미지 올리고 받는 등 추가 작업 있을 시 추가해서 이미지 주소를 가져온다.
            resolve({
              default: '여기에 이미지 주소 넣으면 됩니다.',
              // data-id 속성 추가
              "data-id": 1234
            });
          });
        });
      },
    };
  };

이리저리 검색해보니 기본적으로 ckeditor 에서는 Image 속성에 적용되는 attribute가 제한되어있어서 위 코드처럼 작성해도 내가 원하는 커스텀(?) attribute 가 추가되지 않는다는 것이었다.

그래서 ckeditor 에서 Image 속성에 내가 원하는 attribute 가 적용이 되도록 추가하는 작업이 필요했다.

ImageUploadEditing 플러그인의 이미지 업로드 완료 이벤트가 발생했을 때 attribute 추가 작업을 실행하면 된다.

(코드 내 속성들은 좀 더 자세히 분석해봐야 할 것 같다..!)

참고: https://github.com/ckeditor/ckeditor5/issues/5204

  const attrPlugin = editor => {
    // 이미지 업로드가 완료되었을 때의 event 를 감지
    editor.plugins.get('ImageUploadEditing').on('uploadComplete', (evt, { data, imageElement }) => {

      // 1)
      editor.model.change(writer => {
        writer.setAttribute('dataId', data.dataId, imageElement)
      })
      
      // 2) 
      editor.model.schema.extend('imageBlock', { allowAttributes: 'dataId' })
      
      // 3) 
      editor.conversion.for('upcast').attributeToAttribute({
        view: 'data-id',
        model: 'dataId',
      })

      // 4) 
      editor.conversion.for('downcast').add(dispatcher => {
        dispatcher.on('attribute:dataId:imageBlock', (evt, data, conversionApi) => {
          if (!conversionApi.consumable.consume(data.item, evt.name)) {
            return
          }
          const viewWriter = conversionApi.writer
          const figure = conversionApi.mapper.toViewElement(data.item)
          const img = figure.getChild(0)

          if (data.attributeNewValue !== null) {
            viewWriter.setAttribute('data-id', data.attributeNewValue, img)
          } else {
            viewWriter.removeAttribute('data-id', img)
          }
        })
      })
    })
  }

이 함수 역시 ckeditor 컴포넌트 config 속성의 extraPlugin 에 추가해주면 된다.

<CKEditor
  editor={Editor}
  data=""
  config={{
    extraPlugins: [uploadPlugin, attrPlugin],
  }}
  />

imageInline 속성에도 적용해주기

ckeditor 의 Image 는 ImageBlock 과 ImageInline 두 가지의 속성이 존재한다.

정확하진 않지만 내가 파악하기로는 다음과 같은 형태인 것 같다.

  • ImageBlock
    • figure 태그로 감싸진 img 태그 구조
    • ex) '이미지 툴바 > 이미지 가운데 정렬, 본문 옆에 배치' 선택 시 해당 구조로 적용됨
    • ImageBlock
  • ImageInline
    • figure 가 아닌 p, span 태그로 감싸져 있는 img 태그 구조
    • ex) '이미지 툴바 > 이미지 줄 안에' 선택 시 해당 구조로 적용됨
    • ImageInline

위의 img 태그에 data attribute 추가하는 과정에서 잘 보면 모두 ImageBlock 에 적용을 하고 있는 것을 볼 수 있을 것이다.

위의 코드는 모두 ImageBlock에만 적용을 했기 때문에 ImageBlock 속성일때만 추가 attribute가 적용되고 ImageInline 속성일때는 추가 attribute 가 적용이 되지 않는 문제가 발생했다.

예를 들어, 가운데 정렬일때는 ImageBlock 속성이라 추가 attribute인 data-id 가 적용이 되고 왼쪽 정렬일때는 ImageInline 속성이라 적용이 안되는 현상이 있었다.

imageInline 은 imageBlock 과는 설정을 약간 다르게 적용해주어야 적용이 되었다.

imageInline 은 dataDowncast 에 attribute를 추가해주고, dispatcher on 설정을 editingDowncast 에서 해주어야 한다.

(자세한 이유는 더 알아봐야겠지만, imageBlock과 imageInline 의 커스텀 속성을 변경할 때 조금 로직이 다르다.. 복잡하다 😭)

imageInline 의 커스텀 설정 참고 stack overflow 링크

const attrPlugin = (editor) => {
  // 이미지 업로드가 완료되었을 때의 event 를 감지
  editor.plugins
    .get('ImageUploadEditing')
    .on('uploadComplete', (evt, { data, imageElement }) => {
    editor.model.change((writer) => {
      writer.setAttribute('dataId', data.dataId, imageElement);
    });

    editor.model.schema.extend('imageBlock', { allowAttributes: 'dataId' });
    // 추가
    editor.model.schema.extend('imageInline', {
      allowAttributes: 'dataId',
    });

    editor.conversion.for('upcast').attributeToAttribute({
      view: 'data-id',
      model: 'dataId',
    });
    
    // ✅ imageInline 속성을 위한 추가
    editor.conversion.for('dataDowncast').attributeToAttribute({
      model: 'dataId',
      view: 'data-id',
    })
    
    // ✅ imageInline 속성을 위한 추가
    editor.conversion.for('editingDowncast').add(dispatcher => {
      dispatcher.on('attribute:dataId:imageInline', (evt, data, { writer, consumable, mapper }) => {
        if (!consumable.consume(data.item, evt.name)) {
          return
        }
        const imageContainer = mapper.toViewElement(data.item)
        const imageElement = imageContainer.getChild(0)
        if (data.attributeNewValue !== null) {
          writer.setAttribute('data-id', data.attributeNewValue, imageElement)
        } else {
          writer.removeAttribute('data-id', imageElement)
        }
      })
    })

    editor.conversion.for('downcast').add((dispatcher) => {
      dispatcher.on(
        'attribute:dataId:imageBlock',
        (evt, data, conversionApi) => {
          if (!conversionApi.consumable.consume(data.item, evt.name)) {
            return;
          }
          const viewWriter = conversionApi.writer;
          const figure = conversionApi.mapper.toViewElement(data.item);
          const img = figure.getChild(0);

          if (data.attributeNewValue !== null) {
            viewWriter.setAttribute('data-id', data.attributeNewValue, img);
          } else {
            viewWriter.removeAttribute('data-id', img);
          }
        }
      );
    });
  });
};
profile
🌸 좋은 코드를 작성하고 싶은 프론트엔드 개발자 ✨

2개의 댓글

comment-user-thumbnail
2023년 6월 29일

잘 보았읍니다. 이거 온라인빌더에도 적용 가능 한가요?

1개의 답글