RichEditor | CoUI
본문으로 건너뛰기
LogoCoUI

RichEditor

서식 툴바가 붙은 WYSIWYG Markdown 글·댓글 작성기

목차

글이나 댓글을 쓰는 WYSIWYG Markdown 작성기입니다. 서식 툴바(제목 1–3 | 굵게 · 기울임꼴 · 취소선 · 코드 · 링크 | 글머리 기호 목록 · 번호 목록 · 체크리스트 | 인용 · 이미지)가 TextArea 모양의 상자 안쪽 위에 놓입니다 — 상자의 테두리와 포커스 링이 툴바까지 감싸고, 툴바 아래의 가는 선과 그룹 사이의 세로 선이 영역을 나눕니다. 값은 GitHub-flavoured Markdown 으로 들어가고 나옵니다. 사용자는 Markdown 기호가 아니라 서식이 적용된 결과를 보고 편집합니다. 이미지 버튼은 앱이 업로드와 삭제를 둘 다 구현했을 때(onImageUpload + onImageDelete)만 나타납니다. Flutter/Web 양쪽이 같은 이름의 파라미터를 제공합니다.

기본 사용법 (Basic Usage)#

// 댓글 작성기 — 값은 Markdown 으로 들어가고 나온다
RichEditor(
  placeholder: 'Write a comment…',
  onChanged: (markdown) => draft = markdown,
  onSubmit: (markdown) => post(markdown), // ⌘↩ / Ctrl+Enter
)

// 글자 수 제한 — 한도의 80% 부터 `113 / 140` 카운터가 나타난다
RichEditor(
  placeholder: 'Write a comment…',
  maxLength: 140,
  onChanged: handleChange,
)

// 툴바 없이 — 단축키와 Markdown 자동 서식은 그대로 동작
RichEditor(
  placeholder: 'Write a comment…',
  showToolbar: false,
  onChanged: handleChange,
)

// 컨트롤러로 값 읽기·바꾸기 (Flutter 전용)
final controller = RichEditorController(markdown: '**Hello**');
RichEditor(controller: controller);
controller.markdown = '- one\n- two';

// 이미지 — 업로드와 삭제가 한 쌍이어야 툴바에 이미지 버튼이 나타난다
RichEditor(
  placeholder: 'Write a comment…',
  onImageUpload: (file) => api.uploadImage(file.name, file.bytes), // → 이미지 주소
  onImageDelete: (url) => api.deleteImage(url),
  maxImageBytes: 5 * 1024 * 1024,
)
// 댓글 작성기 — 값은 Markdown 으로 들어가고 나온다
RichEditor(
  placeholder: 'Write a comment…',
  onChanged: (markdown) => draft = markdown,
  onSubmit: (markdown) => post(markdown), // ⌘↩ / Ctrl+Enter
)

// 글자 수 제한 — 한도의 80% 부터 `113 / 140` 카운터가 나타난다
RichEditor(
  placeholder: 'Write a comment…',
  maxLength: 140,
  onChanged: handleChange,
)

// 툴바 없이 — 단축키와 Markdown 자동 서식은 그대로 동작
RichEditor(
  placeholder: 'Write a comment…',
  showToolbar: false,
  onChanged: handleChange,
)

// 이미지 — 업로드와 삭제가 한 쌍이어야 툴바에 이미지 버튼이 나타난다
RichEditor(
  placeholder: 'Write a comment…',
  onImageUpload: (file) => api.uploadImage(file.name, file.bytes), // → 이미지 주소
  onImageDelete: (url) => api.deleteImage(url),
  maxImageBytes: 5 * 1024 * 1024,
)

툴바 구성은 toolbar 로 바꿉니다. 바깥 목록이 그룹이고, 그룹 사이에 간격과 세로 선이 들어갑니다. 목록에서 뺀 서식은 버튼만 사라지고 단축키는 남습니다. 이미지 버튼(CoreRichEditorFormat.image)은 목록에 있어도 onImageUpload 와 onImageDelete 가 둘 다 없으면 나타나지 않고, 그 때문에 빈 그룹이 되면 간격도 남지 않습니다.

RichEditor(
  placeholder: 'Write a comment…',
  toolbar: const [
    [CoreRichEditorFormat.bold, CoreRichEditorFormat.italic, CoreRichEditorFormat.link],
    [CoreRichEditorFormat.bulletedList],
  ],
)

툴바 오른쪽 끝에 앱의 컨트롤 두기#

toolbarTrailing 에 넘긴 위젯(Web 은 컴포넌트)은 툴바의 오른쪽 끝에 놓입니다 — 예: "바뀐 부분 표시" 체크박스. 버튼 옆에 들어갈 자리가 있으면 같은 줄 맨 오른쪽에, 없으면 버튼 아래 줄로 내려가 왼쪽에 놓이고, 두 줄 사이는 toolbarRowSpacing(기본 4)입니다. 툴바 영역을 누르는 것은 편집이 아니므로 이 컨트롤을 눌러도 편집기가 포커스를 받지 않습니다. 체크박스 · 버튼처럼 누르는 컨트롤용입니다 — Web 은 툴바 띠 전체가 mousedown 을 막아 편집 면의 포커스를 지키므로, 글자를 입력하는 칸은 마우스로 포커스를 받을 수 없습니다.

RichEditor(
  placeholder: 'Write a comment…',
  toolbarTrailing: Checkbox(label: '바뀐 부분 표시', checked: show, onChanged: onShow),
)

툴바를 편집기 밖에서 쓰기 (Flutter 전용)#

RichEditor 의 툴바는 showToolbar: false 로 끄고, 같은 버튼을 RichEditorToolbar 로 원하는 자리에 둘 수 있습니다. 편집기가 내부에서 그리는 바로 그 막대(32 정사각 아이콘 버튼 · 툴팁 · 켜진 서식 표시 · 11개 로케일 라벨)이고, 컨트롤러에 묶여 캐럿을 따라 켜지고 꺼집니다. 컨테이너는 그리지 않으므로 디자인이 요구하는 바(바탕 · 안쪽 여백 · 아래 줄)는 앱이 감쌉니다. 상자 안의 툴바(위)와 달리 그룹 사이의 세로 선도 그리지 않습니다 — 앱의 디자인이 정합니다.

final controller = RichEditorController(markdown: '# 약관');
final editorKey = GlobalKey<RichEditorState>();

Column(
  crossAxisAlignment: CrossAxisAlignment.stretch,
  children: [
    ColoredBox(
      color: Theme.of(context).colorScheme.surfaceContainerLow.toValue(),
      child: Padding(
        padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          spacing: 4,
          children: [
            RichEditorToolbar(controller: controller, editorKey: editorKey),
            Checkbox(label: '바뀐 부분 표시', checked: show, onChanged: onShow),
          ],
        ),
      ),
    ),
    RichEditor(key: editorKey, controller: controller, showToolbar: false),
  ],
)
옵션하는 일
controller (필수) 서식을 걸 RichEditorController. 막대가 이 컨트롤러를 듣고 켜진 버튼을 바꿉니다
editorKey 화면에 있는 편집기의 GlobalKey<RichEditorState> . 누른 뒤 포커스를 편집기로 돌리고, 링크 버튼이 편집기의 링크 창을 열고, 이미지 버튼이 편집기가 파일을 받을 수 있을 때( onImageUpload + onImageDelete ) 나타납니다. 편집기가 막대보다 늦게 그려져도 다음 프레임에 이어집니다
toolbar · enabled 버튼 구성(RichEditor.toolbar 와 같은 모양)과 전체 끄기. 컨트롤러가 readOnly 여도 꺼집니다
onFormat 누를 때마다 가장 먼저 불리고, true 를 돌려주면 막대의 기본 동작을 건너뜁니다 — 편집기가 아닌 보기 화면 위에서 누르면 편집으로 바꾸고 싶을 때
trailing 막대의 오른쪽 끝에 놓을 위젯. 버튼 옆에 들어갈 자리가 있으면 같은 줄 맨 오른쪽, 없으면 버튼 아래 줄 왼쪽(두 줄 사이 toolbarRowSpacing )
richEditorStyle 막대의 toolbar* 슬롯(버튼 · 켜진 버튼 · 아이콘 · 간격 · 줄 간격 · 툴팁 · 키캡)
  • 링크 버튼은 editorKey 도 onFormat 도 없으면 꺼져 있습니다 — 링크 창은 편집기의 것입니다.
  • 막대 하나가 편집기 둘을 맡을 수 있습니다. 마지막으로 포커스를 받은 편집기의 컨트롤러와 키를 넘기면 됩니다.
  • 좁으면 그룹 단위로 줄을 바꿉니다. 한 그룹 안에서는 바뀌지 않습니다.
  • 같은 줄 · 아래 줄 배치는 trailing 하나로 정해집니다. 앱 Figma 의 SH-A11 처럼 1440 에서는 체크박스가 버튼과 같은 줄 맨 오른쪽(바 폭 700), 1024 에서는 아래 줄 왼쪽(바 폭 524)으로 갈리는 것도 이 규칙 하나입니다.

Live Preview#

사용 시기 (When to Use)#

이 컴포넌트를 사용하세요:

  • 댓글, 게시글, 리뷰 답글처럼 굵게 · 목록 · 인용 같은 가벼운 서식이 필요한 글을 받을 때
  • 저장 형식이 Markdown 이고, 쓰는 사람에게는 기호 대신 서식을 보여 주고 싶을 때
  • ⌘↩ / Ctrl+Enter 로 바로 보내는 작성 흐름이 필요할 때 (onSubmit)
  • 글에 사진을 붙이고 싶고, 파일을 앱의 서버에 올리고 지우는 코드를 앱이 가지고 있을 때 (onImageUpload · onImageDelete)

대신 다른 컴포넌트를 사용하세요:

  • TextArea: 서식 없는 여러 줄 평문 — 라벨 · 설명 · 에러 · 리사이즈가 필요할 때
  • TextField: 한 줄 입력
  • Kbd: 단축키를 입력이 아니라 표시만 할 때

Props / Parameters#

양 플랫폼의 이름 · 순서 · 기본값이 같습니다. controller / focusNode 는 Flutter 전용 입력 인프라이고, Web 에서는 타입이 Null 이라 항상 비어 있습니다.

속성타입기본값설명
initialValue String? null 처음 보여 줄 Markdown (비제어 모드). controller 가 있으면 무시
onChanged CoreValueChanged<String>? null 바뀔 때마다 편집기의 Markdown 으로 호출
onSubmit CoreValueChanged<String>? null ⌘↩ / Ctrl+Enter 에서 Markdown 으로 호출. null 이면 단축키가 묶이지 않음
placeholder String? null 빈 편집기에 보이는 안내
enabled bool true 입력 가능 여부. 끄면 흐려지고 툴바도 꺼짐
readOnly bool false 선택 · 복사는 되지만 바꿀 수 없음
autofocus bool false 처음 나타날 때 포커스를 받음
maxLength int? null 받을 수 있는 Markdown 최대 글자 수. 한도의 80% 부터 카운터 표시
showToolbar bool true 서식 툴바 표시 여부. 단축키는 툴바와 무관하게 동작
toolbar List<List<CoreRichEditorFormat>> CoreRichEditorFormat.defaultToolbar 툴바 버튼 그룹. 그룹 사이에 간격과 세로 선
toolbarTrailing Widget? (Web Component?) null 툴바 오른쪽 끝에 놓을 앱의 컨트롤. 자리가 없으면 버튼 아래 줄로 내려감
onLinkRequest CoreRichEditorLinkRequest? null 링크 버튼 / ⌘K 에서 링크 주소를 묻는 함수. null 이면 편집기의 작은 다이얼로그
onImageUpload CoreRichEditorImageUpload? null 사용자가 고른 이미지를 서버에 올리고 그 주소를 돌려주는 함수. onImageDelete 와 함께 있어야 이미지 버튼이 나타남 (업로드만 넘기면 디버그 빌드에서 assert)
onImageDelete CoreRichEditorImageDelete? null 사용자가 문서에서 뺀 이미지를 서버에서 지우는 함수. 업로드의 짝이라 없으면 이미지 버튼이 없음 . 서버가 알아서 정리하는 앱은 아무것도 하지 않는 함수를 넘김
maxImageBytes int? 10 * 1024 * 1024 읽고 올릴 이미지의 최대 바이트. null 이면 제한 없음
richEditorStyle CoreRichEditorStyle? null 모든 chrome 이 지나는 단일 슬롯 (아래 스타일 시스템)
controller Flutter RichEditorController? / Web Null null 문서를 들고 Markdown 을 읽고 쓰는 컨트롤러 (Flutter 전용)
focusNode Flutter FocusNode? / Web Null null 키보드 포커스 관리 (Flutter 전용)

CoreRichEditorImageUpload 는 Future<String> Function(CoreRichEditorImageFile file), CoreRichEditorImageDelete 는 Future<void> Function(String url) 입니다. CoreRichEditorImageFile 은 고른 파일의 name · mimeType · bytes(와 size)를 담습니다. 자세한 규칙은 동작 스펙의 '이미지' 절을 보세요.

CoreRichEditorLinkRequest 는 Future<String?> Function(String? current) 입니다. current 는 편집 중인 링크(새 링크면 null)이고, 새 주소 · 링크 제거('') · 취소(null) 중 하나로 완료합니다. 주소는 CoreRichEditorContract.normalizeLink 를 거쳐 http / https / mailto 만 받습니다 — javascript: 같은 주소는 문서에 들어가지 않습니다.

스타일 시스템 (Style System)#

richEditorStyle 슬롯#

RichEditor 는 다른 컴포넌트를 합성하고 그 chrome 을 직접 그리지 않습니다. 상자는 TextArea 의 것(textAreaStyle)이고, 툴바는 그 상자 안쪽 위의 띠(toolbarBackgroundColor · toolbarPadding)와 그 아래 · 그룹 사이의 선(Divider — toolbarDividerStyle, 그룹 사이 길이는 toolbarGroupDividerHeight)입니다. 툴바 버튼은 plain Button(toolbarButtonStyle)과 Icon(toolbarIconStyle), 힌트는 Tooltip(tooltipStyle) 안의 Kbd(kbdStyle), 간격은 Gap 입니다. 이 슬롯들은 그대로 자식 컴포넌트에 전달됩니다. 이미지 업로드 중 · 실패를 알리는 줄은 Alert 이고 uploadAlertStyle 슬롯이 그대로 전달합니다. 문서 자체의 표시 — 제목 · 코드 · 인용 · 링크 · 구분선 · 이미지 — 만 평평한 필드로 둡니다. 제목 1–3 의 글자는 heading1TextStyle · heading2TextStyle · heading3TextStyle(기본: 제목 큰 · 중간 · 작은 역할)이고, 제목 4–6 은 제목 3 의 스타일을 따릅니다.

RichEditor(
  placeholder: 'Write a comment…',
  richEditorStyle: const CoreRichEditorStyle(
    textAreaStyle: CoreTextAreaStyle(                     // ← 상자는 TextArea 의 슬롯
      borderRadius: CoreBorderRadius.all(CoreRadius.radius12),
    ),
    maxHeight: CoreSize.size80 * 4,                        // ← 스크롤 전까지 자라는 높이
    quoteBarColor: CoreColor.token(CoreColors.primary),    // ← 문서 표시
  ),
)

Resolve chain#

design system default (CoreRichEditorStyle.defaultX static const)
  → CoreRichEditorTheme.style                      // 프로젝트 공통
  → widget.richEditorStyle                         // 인스턴스별

자식 슬롯(textAreaStyle · toolbarButtonStyle …)은 이 체인으로 합쳐진 뒤 풀리지 않은 채 TextArea · Button · Icon · Gap · Tooltip · Kbd 의 resolver 에 넘어갑니다. activeToolbarButtonStyle 은 서식이 켜진 버튼에서 toolbarButtonStyle 위에 합쳐집니다.

CoreRichEditorStyle 필드#

필드타입설명
textAreaStyle CoreTextAreaStyle? The box — border, radius, fill, padding, focus ring, text, placeholder, selection and caret colour — handed to the TextArea resolver as is.
minHeight double? Height of the editing area when it holds little text.
maxHeight double? Height the editing area grows to before it scrolls.
toolbarButtonStyle CoreButtonStyle? Each toolbar button, forwarded to its plain Button.
activeToolbarButtonStyle CoreButtonStyle? Merged over [toolbarButtonStyle] while the button's format is on.
toolbarIconStyle CoreIconStyle? Each toolbar button's icon, forwarded to its Icon.
toolbarItemSpacing double? Space between the buttons of one group.
toolbarGroupGapStyle CoreGapStyle? Gap between toolbar groups.
toolbarGapStyle CoreGapStyle? Gap between the box and the rows above it (the upload rows).
toolbarBackgroundColor CoreColor? Fill of the toolbar strip at the top of the box ( surface-container ); none while the editor is disabled.
toolbarPadding CoreEdgeInsets? Inset of the buttons inside the strip (8 at the sides, 4 above and below).
toolbarDividerStyle CoreDividerStyle? The line under the strip and the lines between groups, forwarded to Divider ( outline-variant ). While disabled the line under the strip turns disabled-outline unless a colour is stated; the lines between groups keep their colour.
toolbarGroupDividerHeight double? Length of the line between two groups (20); 0 draws none.
toolbarRowSpacing double? Space between the rows of a toolbar that wraps (4).
tooltipStyle CoreTooltipStyle? The toolbar buttons' tooltip, forwarded to Tooltip.
kbdStyle CoreKbdStyle? The shortcut keys inside the tooltip, forwarded to Kbd.
blockSpacing double? Space below each block of the document.
linkColor CoreColor? Colour of link text and its underline.
codeTextStyle CoreTextStyle? Text of inline code and literal blocks. Its size, weight and family apply; the colour stays the text's own.
codeBackgroundColor CoreColor? Fill behind inline code and literal blocks.
codePadding CoreEdgeInsets? Inset of a literal block's text inside its box.
quoteBarColor CoreColor? Colour of the bar beside a quote.
quoteBarWidth double? Width of the bar beside a quote.
quoteSpacing double? Space between the quote bar and the quoted text.
quoteTextColorCoreColor?Colour of quoted text.
ruleColorCoreColor?Colour of a horizontal rule.
completedTaskColor CoreColor? Colour of a checked task's text.
heading1TextStyle CoreTextStyle? Text of a level-1 heading. Its size, weight and family apply; the colour stays the text's own.
heading2TextStyle CoreTextStyle? Text of a level-2 heading. Its size, weight and family apply; the colour stays the text's own.
heading3TextStyle CoreTextStyle? Text of a level-3 heading. Its size, weight and family apply; the colour stays the text's own.
imageBorderRadius CoreBorderRadius? Corner of an image.
imageMaxHeight double? Height an image is scaled down to when it is taller; a smaller image keeps its size.
imageBackgroundColor CoreColor? Fill behind an image: what shows while it loads, and through its transparent parts.
uploadAlertStyle CoreAlertStyle? The row above the box that reports an image upload under way or failed, and the note that stands in for an image that cannot be shown (Flutter), forwarded to their Alert .

테마 커스터마이징 (Theme)#

CoreComponentTheme(
  richEditor: CoreRichEditorTheme(
    style: CoreRichEditorStyle(
      linkColor: CoreColor.token(CoreColors.primary),
      codeBackgroundColor: CoreColor.token(CoreColors.surfaceContainer),
    ),
  ),
)

Resolve 우선순위: widget.richEditorStyle > CoreRichEditorTheme.style > 디자인 시스템 기본값.

동작 스펙 (Behavior)#

상자는 TextArea 와 같은 모양 · 테두리 · 포커스 경계를 씁니다. 편집 영역은 내용이 적을 때 minHeight(기본 80, 약 세 줄)에서 시작해 maxHeight(기본 240)까지 자라고, 그 뒤로는 스크롤됩니다.

인터랙션#

  • 툴바 버튼: 누르면 캐럿 · 선택 영역에 서식을 적용하거나 해제합니다. 캐럿 위치에서 켜져 있는 서식의 버튼은 hover 채움이 유지되어 "눌린" 상태로 보입니다.
  • 제목 버튼: 캐럿의 블록을 제목 1 · 2 · 3 으로 바꾸고, 이미 그 제목이면 다시 문단으로 돌립니다. 한 블록은 한 종류라서 목록 항목이나 인용 줄에서 누르면 그 블록이 제목이 됩니다. 제목 4–6(#### …)이 들어 있는 Markdown 은 그대로 보존되고 제목 3 의 모양으로 그려집니다.
  • 툴팁: 버튼에 마우스를 올리면 서식 이름과 단축키 키캡(⌘ E, Windows/Linux 는 Ctrl E)이 Tooltip 으로 나타납니다.
  • 제출: ⌘↩ / Ctrl+Enter 에서 onSubmit 이 현재 Markdown 으로 호출됩니다. onSubmit 이 없으면 이 조합은 편집기가 가로채지 않습니다.
  • 링크: 링크 버튼이나 ⌘K 에서 onLinkRequest 를 부르고, 없으면 편집기의 다이얼로그(주소 · 제거 · 취소 · 확인)를 엽니다. 안전하지 않은 주소는 거절하고 오류를 보여 줍니다.
  • 글자 수: maxLength 가 있으면 Markdown 길이가 한도의 80%(counterThreshold)에 닿을 때 상자 아래 끝에 길이 / 한도 카운터가 나타나고, 한도에 닿으면 에러 색으로 바뀝니다. 한도를 넘는 입력과 붙여넣기는 잘립니다.

단축키#

Mod 는 Apple 플랫폼에서 ⌘, 그 밖에서는 Ctrl 입니다. 두 플랫폼이 CoreRichEditorFormat 의 같은 표를 읽으므로 서식마다 키가 같습니다. 툴바에서 뺀 서식도 단축키는 남습니다.

서식단축키 (Apple)단축키 (그 밖)Markdown
굵게 (bold)⌘ BCtrl B**bold**
기울임꼴 (italic)⌘ ICtrl I*italic*
취소선 (strikethrough) ⇧ ⌘ X Ctrl Shift X ~~strike~~
코드 (code)⌘ ECtrl E`code`
링크 (link)⌘ KCtrl K[text](url)
글머리 기호 목록 (bulletedList) ⇧ ⌘ 8 Ctrl Shift 8 - item
번호 목록 (numberedList) ⇧ ⌘ 7 Ctrl Shift 7 1. item
체크리스트 (taskList) ⇧ ⌘ 9 Ctrl Shift 9 - [ ] item
인용 (quote)⇧ ⌘ .Ctrl Shift .> quote
제목 1 (heading1) ⌥ ⌘ 1 Ctrl Alt 1 # Heading
제목 2 (heading2) ⌥ ⌘ 2 Ctrl Alt 2 ## Heading
제목 3 (heading3) ⌥ ⌘ 3 Ctrl Alt 3 ### Heading
이미지 (image)없음 (버튼만)없음 (버튼만)![alt](url)
제출 (onSubmit)⌘ ↩Ctrl Enter—

키는 Shift 를 누르지 않은 글자로 판정합니다 — 대부분의 자판에서 Shift+8 은 * 를 치지만 단축키는 8 로 읽습니다. Alt(Option)와 함께 쓰는 제목 단축키는 글자가 아니라 눌린 물리 키(숫자 1 · 2 · 3)로 판정합니다 — Mac 에서 Option+1 은 ¡ 를 치기 때문입니다. Windows · Linux 에서 AltGr(오른쪽 Alt)로 문자를 치는 자판(여러 유럽 자판)은 AltGr+숫자가 문자 입력으로 가야 하므로 제목 단축키로 읽지 않습니다 — 그 자판에서는 툴바 버튼이나 # 자동 서식을 쓰세요.

Markdown 자동 서식#

입력하는 동안 Markdown 이 인식되는 순간 서식으로 바뀝니다.

입력결과
**굵게** · *기울임* · ~~취소선~~ · `코드` 닫는 기호를 치는 순간 해당 인라인 서식
[텍스트](주소)링크
줄 머리의 # · ## · ### 제목 1–3
줄 머리의 - · * 글머리 기호 목록
줄 머리의 1. 번호 목록
줄 머리의 [] · [ ] · - [ ] 체크리스트
줄 머리의 > 인용
--- 다음 Enter구분선

빈 목록 항목이나 빈 인용 줄에서 Enter 를 누르면 그 블록에서 빠져나옵니다. 편집기가 보여 줄 수 없는 Markdown(표 · HTML · 펜스 코드 블록 · 주소가 안전하지 않은 이미지 — Flutter 에서는 문장 안에 섞인 이미지도)은 리터럴 블록으로 남고, 내보낼 때 바뀌지 않고 그대로 쓰입니다.

이미지#

이미지 버튼은 onImageUpload 와 onImageDelete 가 둘 다 있을 때만 툴바에 나타납니다. 파일을 어디에 두는지, 어떻게 지우는지 편집기는 모르기 때문에 서버 코드가 없으면 버튼도 없고 편집기는 네트워크에 손대지 않습니다. 둘을 한 쌍으로 묶은 이유는 업로드만 있으면 사용자가 문서에서 뺀 이미지가 서버에 영원히 남기 때문입니다. 업로드만 넘기면 디버그 빌드에서 assert 로 알려 주고, 서버가 알아서 정리하는 앱은 아무것도 하지 않는 onImageDelete 를 넘기면 됩니다. Markdown 에 이미 ![설명](주소) 한 줄이 있으면 버튼과 상관없이 이미지로 그려집니다.

  1. 고르기 — 버튼이 플랫폼의 파일 선택창을 엽니다(PNG · JPEG · GIF · WebP). 그 밖의 형식이나 maxImageBytes 를 넘는 파일은 파일을 읽기도 전에 거절하고 상자 위에 실패 줄을 보여 줍니다. 이때 업로드 함수는 불리지 않습니다.
  2. 올리기 — onImageUpload(file) 이 도는 동안 상자 위에 "이미지를 올리는 중…" 줄이 있습니다. 함수는 서버에 파일을 저장하고 이미지를 보여 줄 주소를 돌려줍니다. 함수가 던지거나 편집기가 거절하는 주소(javascript: · file: 등)를 돌려주면 줄이 실패로 바뀌고 ✕ 로 닫습니다.
  3. 넣기 — 주소가 돌아오면 버튼을 누른 때 캐럿이 있던 블록 아래에 이미지 블록이 들어갑니다 (그 블록이 빈 문단이면 그 자리를 대신합니다). 캐럿은 그 아래 문단으로 옮겨 가고, Markdown 에는 ![파일이름](주소) 한 줄이 쓰입니다. 업로드가 도는 동안 계속 글을 써도 됩니다.
  4. 지우기 — 이미지 블록을 선택하고 Backspace / Delete 로 지웁니다(잘라내기도 됩니다).

이미지는 imageMaxHeight(기본 192)보다 크면 비율을 지키며 줄어들고, 모서리는 imageBorderRadius 입니다. 불러오지 못한 이미지는 Flutter 에서는 그 자리에 "이미지를 불러올 수 없음" Alert(uploadAlertStyle)로, Web 에서는 브라우저의 깨진 이미지 상자(대체 텍스트 · 같은 문구의 툴팁)로 보입니다. 두 경우 모두 Markdown 의 줄은 그대로 남습니다.

서버에서 지우기

onImageDelete 는 사용자가 문서에서 뺀 이미지를 서버에서 지우는 자리입니다. 파일을 잃지 않도록 다음 규칙으로만 불립니다.

  • 편집기가 올린 이미지만 — 편집기가 시작할 때 Markdown 에 이미 있던 이미지는 부르지 않습니다. 그 편집을 저장할지는 편집기가 알 수 없고, 취소한 편집이 원래 글의 파일을 지우면 안 되기 때문입니다.
  • 빠진 지 10초 뒤, 한 번만 — 실행 취소로 돌아오거나 잘라내기 · 붙여넣기로 옮겨진 이미지는 지워지지 않습니다. 같은 주소가 문서에 하나라도 남아 있으면 지우지 않습니다.
  • 편집기가 사라질 때 — 빠진 채 기다리던 이미지는 그때 바로 보고하고, 편집기가 사라진 뒤에 끝난 업로드의 파일도 바로 지웁니다.
  • 보여 줄 수 없는 주소가 돌아왔을 때 — onImageUpload 가 blob: 처럼 문서에 남길 수 없는 주소를 돌려주면 줄은 실패로 바뀌고, 서버에는 파일이 이미 있으므로 그 주소로 onImageDelete 를 바로 부릅니다. 파일을 서버에 두고 쓸 수 없게 만들지 않기 위해서입니다.
  • 바깥에서 내용을 바꾸면 부르지 않습니다 — 글을 보낸 뒤 controller.markdown = '' 로 비워도 보낸 글이 가리키는 파일은 지워지지 않습니다.
  • 기다리지 않고 오류도 잡지 않습니다 — 서버 호출의 오류는 앱의 오류 처리로 갑니다.

실행 취소로 이미 지워진 이미지가 돌아오면 서버에는 파일이 없어 불러올 수 없는 이미지로 보입니다. 되돌릴 수 있는 삭제가 필요하면 onImageDelete 에서 표시만 해 두었다가 글을 저장할 때 지우세요.

예제의 image 변형은 서버 없이 동작합니다. 업로드 함수가 파일을 data: 주소로 바꿔 Markdown 안에 담고(그래서 1 MiB 로 제한), 삭제 함수는 메모리의 가짜 서버에서 항목을 지웁니다. 실제 앱은 두 함수에 서버 호출을 넣으면 됩니다.

이 문서 사이트에 끼워 넣은 Flutter 미리보기는 file_picker 의 웹 구현이 등록되지 않은 빌드라 이미지 버튼을 눌러도 선택창이 열리지 않습니다(FileInput 의 찾아보기 버튼도 같습니다). 선택창까지 눈으로 확인하려면 같은 문서의 Web 미리보기를 쓰세요.

상태 전환#

  • 비제어 / 제어: initialValue 는 처음 한 번만 읽습니다. Flutter 에서 controller 를 주면 문서는 컨트롤러가 들고, controller.markdown 으로 읽고 씁니다. Web 은 문서를 DOM (contenteditable)에 두므로 컨트롤러가 없습니다.
  • 비활성: enabled: false 면 입력 · 툴바 · 단축키가 모두 꺼지고 흐리게 그려집니다. 툴바 띠는 바탕을 그리지 않고(상자의 비활성 바탕이 비칩니다) 툴바 아래 선은 disabled-outline 이 됩니다 — 그룹 사이 세로 선은 그대로입니다(디자이너 비활성 프레임).
  • 읽기 전용: readOnly: true 면 선택 · 복사는 되지만 서식 적용과 입력은 막힙니다.

애니메이션#

자체 애니메이션은 없습니다. 툴팁 · 다이얼로그 · 버튼 hover 는 각 합성 컴포넌트의 모션을 따릅니다.

사용 가이드라인 (Usage Guidelines)#

✅ Do#

저장은 Markdown 으로, 보내기는 onSubmit 으로

RichEditor(
  placeholder: 'Write a comment…',
  maxLength: 2000,
  onChanged: (markdown) => draft = markdown,
  onSubmit: (markdown) => post(markdown),
)

이유: 값이 이미 Markdown 이라 그대로 저장하고 렌더러로 보여 주면 됩니다. ⌘↩ 제출은 키보드만으로 작성 흐름을 끝낼 수 있게 합니다.

❌ Don't#

평문만 받는 칸에 RichEditor 쓰기

// 이름 · 주소처럼 서식이 의미 없는 칸
RichEditor(placeholder: 'Your name')

이유: 값에 ** 같은 Markdown 기호가 섞여 들어옵니다. 서식이 필요 없으면 TextArea 나 TextField 를 씁니다.

접근성 (Accessibility)#

역할#

  • Flutter: 편집 영역은 Semantics(textField: true, multiline: true) 이고 readOnly · enabled 를 함께 노출합니다. 툴바는 CoUISemantics(role: group) 이며 이름은 번역된 richEditorToolbar("서식") 입니다. 각 버튼은 서식 이름을 라벨로, 켜짐 여부를 toggled 로 냅니다.
  • 이미지: 이미지 블록은 대체 텍스트(파일 이름에서 온 alt)를 이름으로 냅니다. 올리는 중 · 실패 줄은 Alert 입니다 — 실패 줄은 즉시 알려지고(Web role="alert"), 올리는 중 줄은 알리지 않습니다.
  • Web: 편집 영역은 contenteditable 요소에 role="textbox" · aria-multiline="true", 필요하면 aria-disabled · aria-readonly · aria-placeholder 를 붙입니다. 툴바는 role="toolbar" + aria-label + aria-controls, 그룹마다 role="group" 이고, 버튼은 aria-label · aria-pressed · aria-keyshortcuts(Meta+Shift+X 형식) · aria-controls 를 가집니다.

키보드#

키FlutterWeb
서식 단축키 (위 표)적용 / 해제적용 / 해제
Mod+EnteronSubmitonSubmit
Tab / Shift+Tab 목록 안에서는 들여쓰기 · 내어쓰기, 그 밖에서는 포커스 이동 포커스 이동 (브라우저 기본)
Mod+Z / Mod+Shift+Z 편집기 자체 실행 취소 · 다시 실행 브라우저의 contenteditable 실행 취소
Mod+C / Mod+X 선택 영역을 Markdown 으로 복사 · 잘라내기 브라우저 기본
Mod+V / Mod+Shift+V Markdown 을 서식으로 붙여넣기 / 평문 붙여넣기 항상 평문으로 붙여넣기
Enter / Space (툴바 버튼)버튼 활성화버튼 활성화

툴바는 ARIA toolbar 의 방향키 roving 포커스를 구현하지 않습니다 — 버튼마다 Tab 정지점이 있습니다.

스크린 리더#

편집 영역의 이름은 placeholder 에서 옵니다(Flutter 는 label, Web 은 aria-placeholder). 툴바 버튼은 번역된 서식 이름(예: "굵게", "체크리스트")을 읽고 켜짐 상태를 함께 알립니다. 글자 수 카운터와 서식 적용 결과는 live region 으로 통보되지 않습니다.

포커스 관리#

  • 진입 — autofocus: true 면 처음 나타날 때 편집 영역이 포커스를 받습니다. 상자 안 글 아래 빈 곳을 눌러도 포커스됩니다.
  • 이탈 — 상자와 툴바 바깥을 누르면 포커스가 풀립니다. 툴바 버튼을 눌러도 선택 영역은 유지되어 서식이 그 선택에 적용됩니다.
  • 트랩 — 없음. 링크 다이얼로그는 Dialog 의 포커스 규칙을 따르고, 닫히면 편집기로 돌아옵니다.
  • 키보드로 들어왔을 때만 바깥 포커스 링을 그립니다(TextArea 와 같은 :focus-visible 모델).

알려진 제약#

  • 툴바 버튼은 32×32 로, 전역 최소 24×24 는 넘지만 roving 포커스가 없어 버튼이 많으면 Tab 이동이 길어집니다. 필요한 서식만 toolbar 에 둡니다.
  • 표 · HTML · 펜스 코드 블록은 편집할 수 없는 리터럴 블록으로 남습니다.
  • 이미지는 파일 선택창으로만 넣습니다 — 붙여넣기와 끌어다 놓기는 지원하지 않습니다(Flutter 에는 클립보드 이미지를 읽는 의존성이 없어 두 플랫폼을 같게 두었습니다). 이미지 블록에는 삭제 버튼이 없어 선택한 뒤 Backspace / Delete 로 지우고, 대체 텍스트는 파일 이름이며 편집기에서 바꾸는 UI 는 없습니다.
  • Web 은 붙여넣기가 항상 평문이고 실행 취소가 브라우저 기본이라, 서식 붙여넣기와 편집기 단위 실행 취소는 Flutter 에만 있습니다.

전역 축(동작 줄이기 · 고대비 · 색 강제 모드 · 시맨틱 역할 · 최소 터치 타겟)은 접근성 페이지가 한 곳에서 다룹니다.

크로스 플랫폼 차이점 (Platform Differences)#

항목FlutterWeb
편집 표면 super_editor 문서 편집기 contenteditable 요소 — 서버가 initialValue 로 첫 화면을 그리고 hydration 뒤 브라우저가 넘겨받음
외부 값 · 포커스 제어 RichEditorController / FocusNode 없음 (controller · focusNode 는 Null)
툴바 위치상자 안쪽 위 — 테두리 · 포커스 링이 툴바까지 감쌈상자 안쪽 위 — 같음
상자 테두리 색의 포커스 기준 편집 면의 포커스 :focus-within — 툴바 버튼에 키보드 포커스가 가도 테두리가 물듦. 포커스 링은 편집 면만 따름
toolbarTrailing Widget? — 누르는 컨트롤 · 입력칸 모두 가능 Component? — 띠가 mousedown 을 막아 입력칸은 마우스로 포커스를 못 받음
툴바를 편집기 밖에서 쓰기 RichEditorToolbar — 컨트롤러에 묶임 없음 (문서가 DOM 에 있어 묶을 컨트롤러가 없다 — 편집기 id 에 묶는 짝은 후속)
실행 취소 편집기 자체 기록 (Mod+Z / Mod+Shift+Z, Windows/Linux 는 Ctrl+Y 도) 브라우저 기본
복사 · 붙여넣기 Markdown 으로 복사, Markdown 을 서식으로 붙여넣기 (Mod+Shift+V 평문) 평문 붙여넣기
Tab목록 · 체크리스트 들여쓰기, 코드 안에서는 공백 두 칸포커스 이동
이미지 파일 고르기 file_picker (OS 선택창, 웹에서는 브라우저 선택창) 숨은 <input type="file">
이미지 블록 선택할 수 있는 블록 노드 문단 안의 <img> — 브라우저의 contenteditable 선택 · 끌어 옮기기
문장 안에 섞인 이미지리터럴 블록 — Markdown 글자 그대로줄 안의 <img> 로 보임
상대 주소(/uploads/a.png)의 이미지웹에서만 불러옴불러옴
불러오지 못한 이미지 그 자리의 Alert (이미지 이름 포함) 브라우저의 깨진 이미지 상자 + 툴팁 — 상자 크기만 imageMaxHeight 에서 옴
파일을 편집기에 끌어다 놓기 지원하지 않음 막음 (브라우저가 blob: / file: 주소의 이미지를 넣지 못하게)
Web 전용 HTML 표면 — attributes · classes · css · id

툴바 구성 · 단축키 표 · 자동 서식 규칙 · 링크 정규화 · 카운터 임계값은 Core 한 곳에서 읽으므로 같습니다. 이미지의 파일 형식 · 크기 검사, 안전한 주소 판정, 업로드 · 삭제 시점(onImageDelete 의 10초 규칙)도 Core 가 소유하고 두 플랫폼이 같은 코드를 부릅니다.

  • TextArea: 서식 없는 여러 줄 입력 — RichEditor 의 상자 모양이 여기서 옵니다
  • Kbd: 툴팁 안 단축키 키캡
  • Tooltip: 툴바 버튼의 이름 · 단축키 힌트
  • Alert: 이미지를 올리는 중 · 실패를 알리는 줄