diff --git a/CHANGELOG.md b/CHANGELOG.md index 00da863..2c371c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,36 @@ with a shared version number. ## [Unreleased] +## [1.1.0] - 2026-08-08 + +### Added + +- **`@devslab/editor-ruler-summernote`** — new package: a [Summernote](https://summernote.org) + plugin (`summernote >= 0.8`). Register with `defineRulerPlugin($)` and add + `'ruler'` to `toolbar` for the ruler-icon dropdown (Show/Hide, Vertical + Ruler, Lock/Clear Guides, cm/inch/px). Options live under the `ruler` key: + `enabled`, `visible`, `unit`, `vertical`, `verticalGutter`, `guides`, + `guideSnap`, `language`. + + Summernote is direct-DOM like Froala, so this adapter reuses the same + behavior rather than approximating it: **whole-table indent and column-width + markers work here too** — the first adapter besides Froala to have them. + Commits go through Summernote's own `editor.afterCommand`, which is the + method that calls `history.recordUndo()`, so a whole drag gesture is exactly + one undo step. + +- Landing page gains a Summernote demo tab (jQuery and Summernote lazy-load + from a CDN only when the tab is opened) and a Summernote integration + section; `examples/summernote` added for one-click StackBlitz. + +### Changed + +- Docs that counted adapters ("all three", "the four packages") are now + count-free or corrected, and the feature split is stated where it matters: + the vertical ruler and guides are on every adapter, while whole-table indent + and column markers are on the direct-DOM adapters (Froala, Summernote). +- README CDN pins bumped `@1.0` → `@1.1`. + ## [1.0.1] - 2026-08-08 ### Fixed @@ -359,7 +389,8 @@ What it deliberately does **not** cover, so these can still improve: records one Froala undo step per gesture. iife build exposes the `EditorRulerFroala` global (core bundled) for CDN use. -[Unreleased]: https://github.com/devslab-kr/editor-ruler/compare/v1.0.1...HEAD +[Unreleased]: https://github.com/devslab-kr/editor-ruler/compare/v1.1.0...HEAD +[1.1.0]: https://github.com/devslab-kr/editor-ruler/compare/v1.0.1...v1.1.0 [1.0.1]: https://github.com/devslab-kr/editor-ruler/compare/v1.0.0...v1.0.1 [1.0.0]: https://github.com/devslab-kr/editor-ruler/compare/v0.16.0...v1.0.0 [0.16.0]: https://github.com/devslab-kr/editor-ruler/compare/v0.15.0...v0.16.0 diff --git a/README.ko.md b/README.ko.md index 1b9e30d..1549744 100644 --- a/README.ko.md +++ b/README.ko.md @@ -23,6 +23,7 @@ Froala·TinyMCE·CKEditor 5·Quill 등 범용 WYSIWYG 에디터에는 줄자가 | [`@devslab/editor-ruler-froala`](packages/editor-ruler-froala) | [Froala WYSIWYG 에디터](https://froala.com) 플러그인 어댑터. | | [`@devslab/editor-ruler-tiptap`](packages/editor-ruler-tiptap) | [Tiptap](https://tiptap.dev) 확장 (v2/v3). | | [`@devslab/editor-ruler-ckeditor5`](packages/editor-ruler-ckeditor5) | [CKEditor 5](https://ckeditor.com/ckeditor-5/) 플러그인 (`ckeditor5 >= 42`). | +| [`@devslab/editor-ruler-summernote`](packages/editor-ruler-summernote) | [Summernote](https://summernote.org) 플러그인 (`summernote >= 0.8`). | ## 빠른 시작 (코어 — 아무 contenteditable) @@ -48,7 +49,7 @@ ruler.refresh(); // 선택/내용이 바뀔 때마다 호출 iife 빌드가 전역 `EditorRuler`를 노출합니다: ```html - + @@ -60,8 +61,8 @@ Froala 어댑터도 동일합니다 — `@devslab/editor-ruler-froala/dist/index | URL | 의미 | |---|---| -| `@1.0.1` | 정확한 버전 고정 — 절대 안 바뀜, 캐시 최장 | -| `@1.0` | `1.0.x` 최신 패치 — 버그픽스 자동 반영, 브레이킹 없음 (권장) | +| `@1.1.0` | 정확한 버전 고정 — 절대 안 바뀜, 캐시 최장 | +| `@1.1` | `1.1.x` 최신 패치 — 버그픽스 자동 반영, 브레이킹 없음 (권장) | | `@latest` (또는 버전 생략) | 항상 최신 릴리스 — 메이저 포함이라 브레이킹 체인지가 예고 없이 들어올 수 있음; jsDelivr가 별칭을 최대 12시간 캐시 | ## 빠른 시작 (Froala) @@ -118,6 +119,26 @@ ClassicEditor.create(element, { 모델 속성이 순수 인라인 CSS로 다운캐스트되고, 드래그가 undo 1스텝입니다. [패키지 README](packages/editor-ruler-ckeditor5) 참조. +## 빠른 시작 (Summernote) + +```bash +npm install @devslab/editor-ruler-summernote summernote +``` + +```ts +import $ from 'jquery'; +import 'summernote'; +import { defineRulerPlugin } from '@devslab/editor-ruler-summernote'; + +defineRulerPlugin($); // 에디터 초기화 전에 1회 +$('#editor').summernote({ + toolbar: [['misc', ['ruler']]], + ruler: { unit: 'cm' }, +}); +``` + +Froala 어댑터와 같은 직접-DOM 방식이라 **테이블 통째 밀기와 컬럼 마커까지 동일하게** 동작합니다. 커밋이 Summernote의 `afterCommand`를 거쳐 드래그가 undo 1스텝입니다. [패키지 README](packages/editor-ruler-summernote) 참조. + `defineRulerPlugin`은 `ruler` 플러그인과 **함께** 툴바 커맨드들을 등록합니다 — 필요한 것을 `toolbarButtons`에 추가하세요: - `rulerOptions` — **권장 단일 버튼**: 줄자 아이콘 드롭다운 하나에 보이기/숨기기 · 세로 줄자 · 가이드 잠금 · 가이드 지우기 + cm / inch / px (활성 상태 체크 표시) @@ -125,7 +146,7 @@ ClassicEditor.create(element, { Froala 옵션: `rulerVisible: false`면 가로 줄자를 숨긴 채 시작(플러그인·툴바는 살아 있어 나중에 토글로 켬), `rulerVertical: true`면 초기화 시 세로 줄자 표시, `rulerVerticalGutter: true`면 세로 줄자의 23px 자리를 처음부터 예약해(`scrollbar-gutter: stable`과 같은 발상) 토글해도 본문이 리플로우되지 않음, `rulerGuides: false`면 가이드선 비활성. -Tiptap·CKEditor 5도 같은 제어를 지원합니다: `visible: false`면 숨긴 채 시작(Tiptap은 `showRuler`/`hideRuler`/`toggleRuler` 커맨드, CKEditor 5는 플러그인의 `show()`/`toggle()`), 그리고 **세로 줄자는 세 어댑터 모두에서 동작합니다** — `vertical: true`면 초기 표시, `verticalGutter: true`면 23px 자리를 예약해 토글해도 본문이 리플로우되지 않음 (Tiptap: `showVerticalRuler`/`toggleVerticalRuler` 커맨드, CKEditor 5: `showVRuler()`/`toggleVRuler()` + 툴바 드롭다운의 세로 줄자 항목). +Tiptap·CKEditor 5·Summernote는 각자의 설정 키 아래에서 같은 제어를 받습니다: `visible: false`면 숨긴 채 시작, `vertical: true`면 초기 세로 줄자 표시, `verticalGutter: true`면 23px 자리를 예약해 토글해도 본문이 리플로우되지 않습니다. **세로 줄자와 가이드선은 전 어댑터 공통**이고, 테이블 통째 밀기와 컬럼 마커는 직접-DOM 어댑터(Froala·Summernote)에 있습니다. 토글은 Tiptap이 커맨드(`toggleRuler`·`toggleVerticalRuler`), 나머지는 플러그인 메서드(`toggle()`·`toggleVRuler()`)입니다. ## 기능 @@ -153,6 +174,7 @@ Tiptap·CKEditor 5도 같은 제어를 지원합니다: `visible: false`면 숨 - [Froala — StackBlitz](https://stackblitz.com/github/devslab-kr/editor-ruler/tree/main/examples/froala) - [Tiptap — StackBlitz](https://stackblitz.com/github/devslab-kr/editor-ruler/tree/main/examples/tiptap) - [CKEditor 5 — StackBlitz](https://stackblitz.com/github/devslab-kr/editor-ruler/tree/main/examples/ckeditor5) +- [Summernote — StackBlitz](https://stackblitz.com/github/devslab-kr/editor-ruler/tree/main/examples/summernote) 로컬: @@ -167,7 +189,7 @@ pnpm install && pnpm build 약속에 포함되는 것: -- 네 패키지 진입점에서 export하는 모든 심볼, 각 어댑터가 받는 옵션·설정 +- 각 패키지 진입점에서 export하는 모든 심볼, 각 어댑터가 받는 옵션·설정 - 테마링에 쓰는 `--edr-*` CSS 커스텀 프로퍼티 - 출력 계약 — 들여쓰기는 순수 인라인 CSS(`margin-left` / `margin-right` / `text-indent`, px)로 기록 diff --git a/README.md b/README.md index 5c143ea..ad789be 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Every classic WYSIWYG editor (Froala, TinyMCE, CKEditor 5, Quill, …) ships wit | [`@devslab/editor-ruler-froala`](packages/editor-ruler-froala) | [Froala WYSIWYG editor](https://froala.com) plugin adapter. | | [`@devslab/editor-ruler-tiptap`](packages/editor-ruler-tiptap) | [Tiptap](https://tiptap.dev) extension (v2/v3). | | [`@devslab/editor-ruler-ckeditor5`](packages/editor-ruler-ckeditor5) | [CKEditor 5](https://ckeditor.com/ckeditor-5/) plugin (`ckeditor5 >= 42`). | +| [`@devslab/editor-ruler-summernote`](packages/editor-ruler-summernote) | [Summernote](https://summernote.org) plugin (`summernote >= 0.8`). | ## Quick start (core, any contenteditable) @@ -48,7 +49,7 @@ ruler.refresh(); // call whenever selection or content changes The iife build exposes an `EditorRuler` global: ```html - + @@ -60,8 +61,8 @@ Version pinning options: | URL | Meaning | |---|---| -| `@1.0.1` | Exact version — never changes, cached longest | -| `@1.0` | Latest `1.0.x` patch — bugfixes auto-applied, no breaking changes (recommended) | +| `@1.1.0` | Exact version — never changes, cached longest | +| `@1.1` | Latest `1.1.x` patch — bugfixes auto-applied, no breaking changes (recommended) | | `@latest` (or no version) | Always the newest release — majors included, so breaking changes can land without warning; jsDelivr caches the alias for up to 12h | ## Quick start (Froala) @@ -118,6 +119,26 @@ ClassicEditor.create(element, { Model attributes down-cast to plain inline CSS; one undo step per drag. See the [package README](packages/editor-ruler-ckeditor5). +## Quick start (Summernote) + +```bash +npm install @devslab/editor-ruler-summernote summernote +``` + +```ts +import $ from 'jquery'; +import 'summernote'; +import { defineRulerPlugin } from '@devslab/editor-ruler-summernote'; + +defineRulerPlugin($); // once, before initializing any editor +$('#editor').summernote({ + toolbar: [['misc', ['ruler']]], + ruler: { unit: 'cm' }, +}); +``` + +Same direct-DOM behavior as the Froala adapter — including whole-table indent and column markers. Commits go through Summernote's `afterCommand`, so a drag is one undo step. See the [package README](packages/editor-ruler-summernote). + `defineRulerPlugin` registers the `ruler` plugin **and** toolbar commands — add what you need to `toolbarButtons`: - `rulerOptions` — **recommended single button**: one ruler-icon dropdown holding Show/Hide, Vertical Ruler, Lock Guides, Clear Guides, plus cm / inch / px (active states checkmarked) @@ -125,7 +146,7 @@ Model attributes down-cast to plain inline CSS; one undo step per drag. See the Froala options: `rulerVisible: false` starts the horizontal ruler hidden (plugin and toolbar stay alive — toggle it on later); `rulerVertical: true` shows the vertical ruler on init; `rulerVerticalGutter: true` reserves the strip's 23px column from the start (like `scrollbar-gutter: stable`) so toggling the vertical ruler never reflows the content; `rulerGuides: false` disables guide lines. -Tiptap and CKEditor 5 support the same controls: `visible: false` starts the ruler hidden (`showRuler`/`hideRuler`/`toggleRuler` commands on Tiptap, the plugin's `show()`/`toggle()` on CKEditor 5), and the **vertical ruler works on all three adapters** — `vertical: true` shows it on init, `verticalGutter: true` reserves its 23px column so toggling never reflows the content (Tiptap: `showVerticalRuler`/`toggleVerticalRuler` commands; CKEditor 5: `showVRuler()`/`toggleVRuler()` plus a Vertical Ruler entry in the toolbar dropdown). +Tiptap, CKEditor 5, and Summernote take the same controls under their own config key: `visible: false` starts the ruler hidden, `vertical: true` shows the vertical ruler on init, and `verticalGutter: true` reserves its 23px column so toggling never reflows the content. **Every adapter has the vertical ruler and guides**; whole-table indent and column markers are on the direct-DOM adapters (Froala, Summernote). Toggling is via commands on Tiptap (`toggleRuler`, `toggleVerticalRuler`) and plugin methods elsewhere (`toggle()`, `toggleVRuler()`). ## Features @@ -153,6 +174,7 @@ One-click sandboxes: - [Froala on StackBlitz](https://stackblitz.com/github/devslab-kr/editor-ruler/tree/main/examples/froala) - [Tiptap on StackBlitz](https://stackblitz.com/github/devslab-kr/editor-ruler/tree/main/examples/tiptap) - [CKEditor 5 on StackBlitz](https://stackblitz.com/github/devslab-kr/editor-ruler/tree/main/examples/ckeditor5) +- [Summernote on StackBlitz](https://stackblitz.com/github/devslab-kr/editor-ruler/tree/main/examples/summernote) Locally: @@ -167,7 +189,7 @@ Stable since **1.0.0**. Breaking changes to the documented API require a major r Covered by that promise: -- Every symbol exported from the four packages' entry points, and the options / config each adapter accepts +- Every symbol exported from the packages' entry points, and the options / config each adapter accepts - The `--edr-*` CSS custom properties used for theming - The output contract — indentation is written as plain inline CSS (`margin-left` / `margin-right` / `text-indent`, in px) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c794ec4..ed9759e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -324,6 +324,13 @@ Then wire the normalization from §3 (cells → table, img → parent block) and call `ruler.refresh()` on selection change, content change, and window resize. Copy an existing adapter's test file as your template. +The Summernote adapter is the worked example of exactly this: it is direct-DOM +like Froala, so it reuses the same table/image normalization and column-marker +math, and only the four host-specific calls differ — `$.summernote.plugins` for +registration, `context.layoutInfo.editable` for the DOM, `editor.getLastRange` +for the selection, and `editor.afterCommand` for the undo boundary (that method +is what calls `history.recordUndo()`). + ## 7. Landmines we already stepped on (so you don't) 1. **jsdom has no layout** — `clientWidth` is 0, which collapses clamp ranges. @@ -372,6 +379,7 @@ packages/editor-ruler/src/ packages/editor-ruler-froala/src/index.ts direct-DOM adapter + toolbar dropdown packages/editor-ruler-tiptap/src/index.ts model adapter (node attribute + transactions) packages/editor-ruler-ckeditor5/src/index.ts model adapter (schema + up/downcast) +packages/editor-ruler-summernote/src/index.ts direct-DOM adapter (jQuery plugin + toolbar dropdown) examples/ one-click StackBlitz projects site/ landing page with per-editor demo tabs ``` diff --git a/examples/ckeditor5/package.json b/examples/ckeditor5/package.json index 7d90cf0..c11ce01 100644 --- a/examples/ckeditor5/package.json +++ b/examples/ckeditor5/package.json @@ -9,7 +9,7 @@ "preview": "vite preview" }, "dependencies": { - "@devslab/editor-ruler-ckeditor5": "^1.0.1", + "@devslab/editor-ruler-ckeditor5": "^1.1.0", "ckeditor5": "^48.4.0" }, "devDependencies": { diff --git a/examples/froala/package.json b/examples/froala/package.json index ca5b395..8677cc0 100644 --- a/examples/froala/package.json +++ b/examples/froala/package.json @@ -9,7 +9,7 @@ "preview": "vite preview" }, "dependencies": { - "@devslab/editor-ruler-froala": "^1.0.1", + "@devslab/editor-ruler-froala": "^1.1.0", "froala-editor": "^4.5.2" }, "devDependencies": { diff --git a/examples/summernote/index.html b/examples/summernote/index.html new file mode 100644 index 0000000..5287e12 --- /dev/null +++ b/examples/summernote/index.html @@ -0,0 +1,18 @@ + + + + + + editor-ruler — Summernote example + + + +
+
+
+ + + diff --git a/examples/summernote/main.js b/examples/summernote/main.js new file mode 100644 index 0000000..8476ff6 --- /dev/null +++ b/examples/summernote/main.js @@ -0,0 +1,36 @@ +import $ from 'jquery'; +import 'summernote/dist/summernote-lite.css'; +import 'summernote/dist/summernote-lite.js'; +import { defineRulerPlugin } from '@devslab/editor-ruler-summernote'; + +// Summernote attaches itself to the jQuery instance it finds, so hand it ours. +window.jQuery = window.$ = $; + +defineRulerPlugin($); + +$('#editor').summernote({ + height: 300, + toolbar: [ + ['style', ['bold', 'italic', 'underline']], + ['para', ['ul', 'ol']], + ['table', ['table']], + ['misc', ['ruler']], + ], + ruler: { + unit: 'cm', + verticalGutter: true, // reserve the vertical ruler's column up front + }, + callbacks: { + onInit() { + $('#editor').summernote( + 'code', + '

editor-ruler + Summernote

' + + '

왼쪽 여백 60px 문단 — 핸들을 끌어 보세요.

' + + '

테이블 셀에 커서를 두면 줄자에 컬럼 마커가 뜨고, 여백 핸들은 테이블 전체를 밀어냅니다.

' + + '' + + '
제품수량가격
editor-ruler1무료
' + + '

툴바의 줄자 드롭다운으로 세로 줄자·가이드 잠금/지우기·단위 전환이 됩니다.

', + ); + }, + }, +}); diff --git a/examples/summernote/package.json b/examples/summernote/package.json new file mode 100644 index 0000000..13978f3 --- /dev/null +++ b/examples/summernote/package.json @@ -0,0 +1,22 @@ +{ + "name": "editor-ruler-example-summernote", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@devslab/editor-ruler-summernote": "^1.1.0", + "jquery": "^3.7.1", + "summernote": "^0.9.1" + }, + "devDependencies": { + "vite": "^5.4.0" + }, + "stackblitz": { + "startCommand": "npm run dev" + } +} diff --git a/examples/tiptap/package.json b/examples/tiptap/package.json index e7060f3..115e4ef 100644 --- a/examples/tiptap/package.json +++ b/examples/tiptap/package.json @@ -9,7 +9,7 @@ "preview": "vite preview" }, "dependencies": { - "@devslab/editor-ruler-tiptap": "^1.0.1", + "@devslab/editor-ruler-tiptap": "^1.1.0", "@tiptap/core": "^3.6.5", "@tiptap/extension-image": "^3.6.5", "@tiptap/starter-kit": "^3.6.5" diff --git a/examples/vanilla/package.json b/examples/vanilla/package.json index 5b7f0ee..f341079 100644 --- a/examples/vanilla/package.json +++ b/examples/vanilla/package.json @@ -9,7 +9,7 @@ "preview": "vite preview" }, "dependencies": { - "@devslab/editor-ruler": "^1.0.1" + "@devslab/editor-ruler": "^1.1.0" }, "devDependencies": { "vite": "^5.4.0" diff --git a/packages/editor-ruler-ckeditor5/package.json b/packages/editor-ruler-ckeditor5/package.json index 811f071..4448b9d 100644 --- a/packages/editor-ruler-ckeditor5/package.json +++ b/packages/editor-ruler-ckeditor5/package.json @@ -1,6 +1,6 @@ { "name": "@devslab/editor-ruler-ckeditor5", - "version": "1.0.1", + "version": "1.1.0", "description": "CKEditor 5 plugin for @devslab/editor-ruler — a Word-like horizontal ruler with margins, first-line indent, and guide lines.", "license": "Apache-2.0", "author": "devslab", diff --git a/packages/editor-ruler-froala/README.ko.md b/packages/editor-ruler-froala/README.ko.md index eb11f76..4fe3b51 100644 --- a/packages/editor-ruler-froala/README.ko.md +++ b/packages/editor-ruler-froala/README.ko.md @@ -40,7 +40,7 @@ new FroalaEditor('#editor', { CDN 사용 — iife 빌드는 코어를 포함한 단일 파일로 전역 `EditorRulerFroala`를 노출합니다: ```html - + diff --git a/packages/editor-ruler-froala/README.md b/packages/editor-ruler-froala/README.md index 32e69b2..47e53bc 100644 --- a/packages/editor-ruler-froala/README.md +++ b/packages/editor-ruler-froala/README.md @@ -40,7 +40,7 @@ If you configure `pluginsEnabled` explicitly, add `'ruler'` to the list. CDN usage — the iife build bundles the core into one file and exposes `EditorRulerFroala`: ```html - + diff --git a/packages/editor-ruler-froala/package.json b/packages/editor-ruler-froala/package.json index 763bc43..ae2d7f6 100644 --- a/packages/editor-ruler-froala/package.json +++ b/packages/editor-ruler-froala/package.json @@ -1,6 +1,6 @@ { "name": "@devslab/editor-ruler-froala", - "version": "1.0.1", + "version": "1.1.0", "description": "Froala WYSIWYG editor adapter for @devslab/editor-ruler — a Word-like horizontal ruler plugin.", "license": "Apache-2.0", "author": "devslab", diff --git a/packages/editor-ruler-summernote/README.ko.md b/packages/editor-ruler-summernote/README.ko.md new file mode 100644 index 0000000..5bced00 --- /dev/null +++ b/packages/editor-ruler-summernote/README.ko.md @@ -0,0 +1,45 @@ +# @devslab/editor-ruler-summernote + +[![npm](https://img.shields.io/npm/v/%40devslab%2Feditor-ruler-summernote)](https://www.npmjs.com/package/@devslab/editor-ruler-summernote) + +**[문서 & 라이브 데모](https://devslab-kr.github.io/editor-ruler/)** · [English](README.md) + +**1.0.0부터 안정 버전** — export되는 API는 semver를 따릅니다. [안정성 범위](https://github.com/devslab-kr/editor-ruler#안정성) 참조. + +[`@devslab/editor-ruler`](https://github.com/devslab-kr/editor-ruler)의 [Summernote](https://summernote.org) 어댑터 — 에디터 위에 Word 스타일 가로 줄자(여백 + 첫 줄 들여쓰기)를 추가합니다. + +```bash +npm install @devslab/editor-ruler-summernote summernote +``` + +```ts +import $ from 'jquery'; +import 'summernote'; +import { defineRulerPlugin } from '@devslab/editor-ruler-summernote'; + +defineRulerPlugin($); // 에디터 초기화 전에 1회 + +$('#editor').summernote({ + toolbar: [['style', ['bold', 'italic']], ['misc', ['ruler']]], + ruler: { + enabled: true, // 기본 true — false면 플러그인 비활성 + visible: true, // false면 숨긴 채 시작 — 나중에 토글로 켬 + unit: 'cm', // 'cm' | 'in' | 'px' + vertical: false, // 초기화 시 세로 줄자 표시 + verticalGutter: false, // 세로 줄자의 23px 자리를 미리 예약 — + // 토글해도 본문이 리플로우되지 않음 + guides: true, // 가이드선 (줄자에서 드래그해 생성) + guideSnap: 5, // 스냅 거리(px); 0이면 비활성 + }, +}); +``` + +`toolbar`에 `'ruler'`를 추가하면 줄자 아이콘 드롭다운이 생깁니다 — 보이기/숨기기·세로 줄자·가이드 잠금/지우기·cm/인치/px 전환이 한 버튼에 들어 있습니다. 플러그인 API는 `$('#editor').data('summernote').modules.ruler`로도 접근합니다 (`show`/`hide`/`toggle`/`isVisible`, `showVRuler`/`toggleVRuler`, `setUnit`, `setGuidesLocked`, `clearGuides`, `refresh`). + +줄자는 현재 선택 위치 문단(들)의 상태를 표시하며, 핸들 드래그는 해당 문단의 `margin-left` / `margin-right` / `text-indent` 인라인 스타일을 갱신합니다. 드래그 제스처 전체가 정확히 **undo 1스텝**입니다 — 커밋이 Summernote 자체의 `afterCommand`를 거치고, 히스토리 스냅샷을 찍는 게 바로 그 함수입니다. + +**테이블·이미지도 Word처럼 밀립니다** (Froala 어댑터와 동일): 테이블 안 선택은 **테이블 전체**를 들여쓰고(CSS는 셀의 margin을 무시합니다), 셀 안 문단은 개별 들여쓰기, 바로 선택된 ``는 소속 블록으로 정규화됩니다. 테이블 안에서는 줄자에 드래그 가능한 **컬럼 경계 마커**가 뜹니다 (병합 셀 테이블은 경계 계산이 모호해 마커를 생략합니다). + +`summernote >= 0.8`과 jQuery peer가 필요합니다. + +라이선스: Apache-2.0 © devslab diff --git a/packages/editor-ruler-summernote/README.md b/packages/editor-ruler-summernote/README.md new file mode 100644 index 0000000..119ed74 --- /dev/null +++ b/packages/editor-ruler-summernote/README.md @@ -0,0 +1,45 @@ +# @devslab/editor-ruler-summernote + +[![npm](https://img.shields.io/npm/v/%40devslab%2Feditor-ruler-summernote)](https://www.npmjs.com/package/@devslab/editor-ruler-summernote) + +**[Docs & live demo](https://devslab-kr.github.io/editor-ruler/)** · [한국어](README.ko.md) + +**Stable since 1.0.0** — the exported API follows semantic versioning; see the [stability scope](https://github.com/devslab-kr/editor-ruler#stability). + +[Summernote](https://summernote.org) adapter for [`@devslab/editor-ruler`](https://github.com/devslab-kr/editor-ruler) — adds a Word-like horizontal ruler (margins + first-line indent) above the editor. + +```bash +npm install @devslab/editor-ruler-summernote summernote +``` + +```ts +import $ from 'jquery'; +import 'summernote'; +import { defineRulerPlugin } from '@devslab/editor-ruler-summernote'; + +defineRulerPlugin($); // once, before initializing any editor + +$('#editor').summernote({ + toolbar: [['style', ['bold', 'italic']], ['misc', ['ruler']]], + ruler: { + enabled: true, // default true — false disables the plugin + visible: true, // false starts it hidden — toggle it on later + unit: 'cm', // 'cm' | 'in' | 'px' + vertical: false, // show the vertical ruler on init + verticalGutter: false, // reserve the vertical ruler's 23px column up + // front, so toggling never reflows the content + guides: true, // guide lines (drag out of a ruler) + guideSnap: 5, // snap distance in px; 0 disables + }, +}); +``` + +Adding `'ruler'` to `toolbar` gives you a ruler-icon dropdown holding Show/Hide, Vertical Ruler, Lock Guides, Clear Guides, and the cm/inch/px switch. The plugin API is also reachable at `$('#editor').data('summernote').modules.ruler` (`show`/`hide`/`toggle`/`isVisible`, `showVRuler`/`toggleVRuler`, `setUnit`, `setGuidesLocked`, `clearGuides`, `refresh`). + +The ruler reflects the paragraph(s) under the current selection; dragging a handle updates their `margin-left` / `margin-right` / `text-indent` inline styles. A whole drag gesture is exactly **one undo step** — the commit goes through Summernote's own `afterCommand`, which is what records the history snapshot. + +**Tables and images push like Word**, the same as the Froala adapter: a selection inside a table indents the **whole ``** (CSS ignores margins on cells), paragraphs inside cells indent individually, and a bare `` resolves to its containing block. Inside a table the ruler shows draggable **column boundary markers** (skipped for tables with merged cells, where the boundary math is ambiguous). + +Requires `summernote >= 0.8` and its jQuery peer. + +License: Apache-2.0 © devslab diff --git a/packages/editor-ruler-summernote/package.json b/packages/editor-ruler-summernote/package.json new file mode 100644 index 0000000..a674f77 --- /dev/null +++ b/packages/editor-ruler-summernote/package.json @@ -0,0 +1,60 @@ +{ + "name": "@devslab/editor-ruler-summernote", + "version": "1.1.0", + "description": "Summernote adapter for @devslab/editor-ruler — a Word-like horizontal ruler plugin.", + "license": "Apache-2.0", + "author": "devslab", + "homepage": "https://devslab-kr.github.io/editor-ruler/", + "repository": { + "type": "git", + "url": "git+https://github.com/devslab-kr/editor-ruler.git", + "directory": "packages/editor-ruler-summernote" + }, + "bugs": "https://github.com/devslab-kr/editor-ruler/issues", + "keywords": [ + "summernote", + "summernote-plugin", + "summernote-ruler", + "ruler", + "wysiwyg", + "plugin", + "indent", + "margin", + "horizontal-ruler", + "editor-ruler" + ], + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist" + ], + "sideEffects": false, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsup", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { + "@devslab/editor-ruler": "workspace:^" + }, + "peerDependencies": { + "summernote": ">=0.8.0" + }, + "peerDependenciesMeta": { + "summernote": { + "optional": true + } + } +} diff --git a/packages/editor-ruler-summernote/src/index.ts b/packages/editor-ruler-summernote/src/index.ts new file mode 100644 index 0000000..396ea66 --- /dev/null +++ b/packages/editor-ruler-summernote/src/index.ts @@ -0,0 +1,533 @@ +import { + createGuides, + createRuler, + createVRuler, + detectLanguage, + resolveRulerLabels, + type Guides, + type GuideSet, + type Ruler, + type RulerChange, + type RulerChangePhase, + type RulerMetrics, + type RulerUnit, + type VRuler, +} from '@devslab/editor-ruler'; + +/** + * Options for the ruler plugin, passed under the `ruler` key of Summernote's + * options object: + * + * ```js + * $('#editor').summernote({ + * toolbar: [['misc', ['ruler']]], + * ruler: { unit: 'cm', vertical: true }, + * }); + * ``` + */ +export interface SummernoteRulerOptions { + /** Turn the plugin off entirely. Default true. */ + enabled?: boolean; + /** + * Whether the horizontal ruler starts visible. Default true. `false` mounts + * it hidden — the toolbar dropdown or `show()` brings it up later. + */ + visible?: boolean; + unit?: RulerUnit; + /** Show the vertical ruler strip on init. Default false. */ + vertical?: boolean; + /** + * Reserve the vertical ruler's 23px column from the start (like CSS + * `scrollbar-gutter: stable`), so toggling it never reflows the content. + * Default false. + */ + verticalGutter?: boolean; + /** Enable guide lines (drag out of a ruler). Default true. */ + guides?: boolean; + /** Snap distance for drags near a guide; 0 disables. Default 5. */ + guideSnap?: number; + /** UI language; null = `` → browser language → en. */ + language?: string | null; +} + +/** The plugin API, reachable via `context.modules.ruler`. */ +export interface SummernoteRulerApi { + refresh(): void; + show(): void; + hide(): void; + toggle(): void; + isVisible(): boolean; + setUnit(unit: RulerUnit): void; + getUnit(): RulerUnit; + showVRuler(): void; + hideVRuler(): void; + toggleVRuler(): void; + isVRulerVisible(): boolean; + setGuidesLocked(locked: boolean): void; + isGuidesLocked(): boolean; + clearGuides(): void; + getGuides(): GuideSet; +} + +/** UI strings for the toolbar dropdown. */ +export interface RulerStrings { + ruler: string; + showHide: string; + verticalRuler: string; + lockGuides: string; + clearGuides: string; + cm: string; + in: string; + px: string; +} + +const STRINGS: Record = { + en: { + ruler: 'Ruler', + showHide: 'Show / Hide', + verticalRuler: 'Vertical Ruler', + lockGuides: 'Lock Guides', + clearGuides: 'Clear Guides', + cm: 'cm', + in: 'inch', + px: 'px', + }, + ko: { + ruler: '줄자', + showHide: '보이기 / 숨기기', + verticalRuler: '세로 줄자', + lockGuides: '가이드 잠금', + clearGuides: '가이드 지우기', + cm: 'cm', + in: '인치', + px: 'px', + }, +}; + +export interface DefineRulerPluginOptions { + /** Language for toolbar strings. Default: `` → browser → 'en'. */ + language?: string; + /** Override any built-in string. */ + strings?: Partial; +} + +const BLOCK_FALLBACK_SELECTOR = 'p, div, h1, h2, h3, h4, h5, h6, li, blockquote, pre'; + +/** + * CSS ignores margins on table *cells*, so a selection inside a table targets + * the whole `
` instead — Word's behavior. `text-indent` is never + * written onto a table. + */ +const TABLE_TAGS = new Set(['TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TR', 'TD', 'TH']); + +const RULER_ICON_SVG = + ''; + +/** + * Registers the `ruler` plugin on Summernote. Call once, before initializing + * any editor: + * + * ```ts + * import $ from 'jquery'; + * import 'summernote'; + * import { defineRulerPlugin } from '@devslab/editor-ruler-summernote'; + * + * defineRulerPlugin($); + * $('#editor').summernote({ toolbar: [['misc', ['ruler']]] }); + * ``` + * + * Also registers a `ruler` toolbar item — a ruler-icon dropdown holding + * Show/Hide, Vertical Ruler, Lock/Clear Guides, and the cm/inch/px switch. + */ +export function defineRulerPlugin(jQuery: any, defineOptions: DefineRulerPluginOptions = {}): void { + const $ = jQuery; + const summernote = $?.summernote; + if (!summernote || summernote.plugins?.ruler) return; + + const doc = typeof document !== 'undefined' ? document : undefined; + const language = detectLanguage(defineOptions.language, doc); + const t: RulerStrings = { ...(STRINGS[language] ?? STRINGS.en!), ...defineOptions.strings }; + + // Summernote merges this into every editor's options object. + summernote.options = $.extend(true, {}, summernote.options, { + ruler: { + enabled: true, + visible: true, + unit: 'cm' as RulerUnit, + vertical: false, + verticalGutter: false, + guides: true, + guideSnap: 5, + language: null, + } satisfies SummernoteRulerOptions, + }); + + summernote.plugins.ruler = function rulerPlugin(this: any, context: any) { + const self = this; + const opts = (): SummernoteRulerOptions => context.options?.ruler ?? {}; + + let mount: HTMLElement | null = null; + let vwrap: HTMLElement | null = null; + let vmount: HTMLElement | null = null; + let ruler: Ruler | null = null; + let vruler: VRuler | null = null; + let guides: Guides | null = null; + let visible = false; + let vVisible = false; + + function editableEl(): HTMLElement { + const el = context.layoutInfo?.editable; + return (el?.[0] ?? el) as HTMLElement; + } + + function contentPadding( + side: 'paddingLeft' | 'paddingRight' | 'paddingTop' | 'paddingBottom', + ): number { + const el = editableEl(); + const win = el.ownerDocument.defaultView!; + return parseFloat(win.getComputedStyle(el)[side]) || 0; + } + + /** 23px = 22px strip + 1px border. Reserved whenever the column exists. */ + function vRulerOffset(): number { + return vVisible || (opts().verticalGutter === true && vwrap) ? 23 : 0; + } + + function alignMount(): void { + if (mount) mount.style.paddingLeft = `${contentPadding('paddingLeft') + vRulerOffset()}px`; + } + + /** + * Resolves a raw block to what the ruler should push: + * table structure → the whole `
`; a bare `` → its block. + */ + function normalizeBlock(b: HTMLElement, el: HTMLElement): HTMLElement | null { + if (!b || b === el || !el.contains(b)) return null; + if (b.tagName === 'IMG') { + const host = b.closest(BLOCK_FALLBACK_SELECTOR) as HTMLElement | null; + return host && host !== el && el.contains(host) ? host : null; + } + if (TABLE_TAGS.has(b.tagName)) { + const table = (b.tagName === 'TABLE' ? b : b.closest('table')) as HTMLElement | null; + return table && el.contains(table) ? table : null; + } + return b; + } + + /** Blocks under the caret, via Summernote's range when available. */ + function rawBlocks(): HTMLElement[] { + const el = editableEl(); + const dom = summernote.dom; + const rng = context.invoke('editor.getLastRange'); + if (rng && typeof rng.nodes === 'function' && dom?.isPara) { + const nodes = rng.nodes(dom.isPara, { includeAncestor: true }) as HTMLElement[]; + if (nodes?.length) return nodes; + } + // Fallback: the editable is a plain contenteditable, so the native + // selection is enough when Summernote has no range yet. + const win = el.ownerDocument.defaultView!; + const sel = win.getSelection?.(); + const node = sel && sel.rangeCount > 0 ? sel.getRangeAt(0).startContainer : null; + const start = (node && (node.nodeType === 1 ? node : node.parentElement)) as HTMLElement | null; + const block = start?.closest(BLOCK_FALLBACK_SELECTOR) as HTMLElement | null; + return block ? [block] : []; + } + + function selectedBlocks(): HTMLElement[] { + const el = editableEl(); + const targets: HTMLElement[] = []; + for (const raw of rawBlocks()) { + const target = normalizeBlock(raw, el); + if (target && !targets.includes(target)) targets.push(target); + } + if (targets.length > 0) return targets; + const fallback = el.querySelector(BLOCK_FALLBACK_SELECTOR); + return fallback ? [fallback as HTMLElement] : []; + } + + function getMetrics(): RulerMetrics { + const el = editableEl(); + const win = el.ownerDocument.defaultView!; + const contentWidth = Math.max( + 0, + el.clientWidth - contentPadding('paddingLeft') - contentPadding('paddingRight'), + ); + const block = selectedBlocks()[0]; + if (!block) return { contentWidth, leftMargin: 0, rightMargin: 0, firstLineIndent: 0 }; + const style = win.getComputedStyle(block); + return { + contentWidth, + leftMargin: parseFloat(style.marginLeft) || 0, + rightMargin: parseFloat(style.marginRight) || 0, + firstLineIndent: parseFloat(style.textIndent) || 0, + }; + } + + function currentTable(): HTMLTableElement | null { + const el = editableEl(); + for (const b of rawBlocks()) { + const table = (b.tagName === 'TABLE' ? b : b.closest?.('table')) as HTMLTableElement | null; + if (table && el.contains(table)) return table; + } + return null; + } + + function contentLeft(): number { + const el = editableEl(); + return el.getBoundingClientRect().left + contentPadding('paddingLeft'); + } + + /** Column boundaries of the selected table in ruler coords, or null. */ + function columnEdges(): number[] | null { + const table = currentTable(); + if (!table) return null; + // Merged cells make the boundary math ambiguous — no markers there. + if (table.querySelector('td[colspan], th[colspan], td[rowspan], th[rowspan]')) return null; + const row = table.querySelector('tr'); + if (!row) return null; + const cells = Array.from(row.children).filter( + (c) => c.tagName === 'TD' || c.tagName === 'TH', + ) as HTMLElement[]; + if (cells.length === 0) return null; + const origin = contentLeft(); + const edges = [table.getBoundingClientRect().left - origin]; + for (const cell of cells) edges.push(cell.getBoundingClientRect().right - origin); + return edges; + } + + /** Records exactly one undo step; Summernote snapshots in afterCommand. */ + function commit(): void { + context.invoke('editor.afterCommand'); + } + + function applyColumnChange(index: number, x: number, phase: RulerChangePhase): void { + const table = currentTable(); + const edges = columnEdges(); + if (!table || !edges) return; + const leftEdge = edges[index - 1]; + const rightEdge = edges[index + 1]; + if (leftEdge === undefined || rightEdge === undefined) return; + const tableWidth = table.getBoundingClientRect().width; + if (!(tableWidth > 0)) return; + const pct = (w: number) => `${((w / tableWidth) * 100).toFixed(4)}%`; + for (const row of Array.from(table.querySelectorAll('tr'))) { + const cells = Array.from(row.children).filter( + (c) => c.tagName === 'TD' || c.tagName === 'TH', + ) as HTMLElement[]; + const leftCell = cells[index - 1]; + const rightCell = cells[index]; + if (leftCell) leftCell.style.width = pct(x - leftEdge); + if (rightCell) rightCell.style.width = pct(rightEdge - x); + } + if (phase === 'commit') commit(); + } + + function applyChange(change: RulerChange, phase: RulerChangePhase): void { + for (const block of selectedBlocks()) { + if (change.leftMargin !== undefined) block.style.marginLeft = `${change.leftMargin}px`; + if (change.rightMargin !== undefined) block.style.marginRight = `${change.rightMargin}px`; + // text-indent is meaningless on a table block. + if (change.firstLineIndent !== undefined && block.tagName !== 'TABLE') + block.style.textIndent = `${change.firstLineIndent}px`; + } + if (phase === 'commit') commit(); + } + + function refresh(): void { + alignMount(); + if (vmount) vmount.style.paddingTop = `${contentPadding('paddingTop')}px`; + ruler?.refresh(); + vruler?.refresh(); + guides?.refresh(); + } + + function ensureVWrap(): void { + if (vwrap) return; + const el = editableEl(); + const d = el.ownerDocument; + const parent = el.parentElement; + if (!parent) return; + vwrap = d.createElement('div'); + vwrap.className = 'edr-vwrap'; + parent.insertBefore(vwrap, el); + vmount = d.createElement('div'); + vmount.className = 'edr-sn-vmount'; + vwrap.appendChild(vmount); + vwrap.appendChild(el); + vmount.style.paddingTop = `${contentPadding('paddingTop')}px`; + vruler = createVRuler(vmount, { + unit: ruler?.getUnit() ?? opts().unit ?? 'cm', + ...(guides ? { guides } : {}), + getMetrics: () => ({ + contentHeight: Math.max( + 0, + el.clientHeight - contentPadding('paddingTop') - contentPadding('paddingBottom'), + ), + }), + }); + } + + function showVRuler(): void { + ensureVWrap(); + if (!vmount) return; + vmount.style.display = ''; + vmount.style.visibility = ''; + vVisible = true; + refresh(); + } + + function hideVRuler(): void { + if (!vmount) return; + if (opts().verticalGutter === true) { + // Keep the reserved column — hide without reclaiming the width. + vmount.style.visibility = 'hidden'; + } else { + vmount.style.display = 'none'; + } + vVisible = false; + refresh(); + } + + function show(): void { + if (!mount) return; + mount.style.display = ''; + visible = true; + refresh(); + } + + function hide(): void { + if (!mount) return; + mount.style.display = 'none'; + visible = false; + } + + const api: SummernoteRulerApi = { + refresh, + show, + hide, + toggle: () => (visible ? hide() : show()), + isVisible: () => visible, + setUnit: (unit) => { + ruler?.setUnit(unit); + vruler?.setUnit(unit); + }, + getUnit: () => ruler?.getUnit() ?? opts().unit ?? 'cm', + showVRuler, + hideVRuler, + toggleVRuler: () => (vVisible ? hideVRuler() : showVRuler()), + isVRulerVisible: () => vVisible, + setGuidesLocked: (locked) => guides?.setLocked(locked), + isGuidesLocked: () => guides?.isLocked() === true, + clearGuides: () => guides?.clear(), + getGuides: () => guides?.list() ?? { x: [], y: [] }, + }; + Object.assign(self, api); + + // ---- toolbar dropdown ---- + const ui = summernote.ui; + if (ui && typeof context.memo === 'function') { + context.memo('button.ruler', () => { + const items: Array<[string, string, () => void]> = [ + ['toggle', t.showHide, () => api.toggle()], + ['vruler', t.verticalRuler, () => api.toggleVRuler()], + ['lockGuides', t.lockGuides, () => api.setGuidesLocked(!api.isGuidesLocked())], + ['clearGuides', t.clearGuides, () => api.clearGuides()], + ['cm', t.cm, () => api.setUnit('cm')], + ['in', t.in, () => api.setUnit('in')], + ['px', t.px, () => api.setUnit('px')], + ]; + const button = ui.buttonGroup([ + ui.button({ + contents: RULER_ICON_SVG, + tooltip: t.ruler, + data: { toggle: 'dropdown' }, + }), + ui.dropdown({ + className: 'dropdown-ruler', + items: items.map(([id]) => id), + template: (id: string) => items.find((i) => i[0] === id)?.[1] ?? id, + click: (event: any) => { + event.preventDefault?.(); + const id = $(event.target).closest('[data-value]').data('value'); + items.find((i) => i[0] === id)?.[2](); + }, + }), + ]); + return button.render(); + }); + } + + this.shouldInitialize = () => opts().enabled !== false; + + this.initialize = function initialize(): void { + const el = editableEl(); + const d = el.ownerDocument; + const parent = el.parentElement; + if (!parent) return; + const o = opts(); + + mount = d.createElement('div'); + mount.className = 'edr-sn-mount'; + parent.insertBefore(mount, el); + + if (o.guides !== false) { + guides = createGuides(parent, { + getOffsetLeft: () => contentPadding('paddingLeft'), + getOffsetTop: () => contentPadding('paddingTop'), + }); + } + + ruler = createRuler(mount, { + unit: o.unit ?? 'cm', + guideSnap: o.guideSnap ?? 5, + getMetrics, + onChange: applyChange, + labels: resolveRulerLabels(o.language ?? context.options?.lang ?? undefined, d), + columns: { get: columnEdges, onChange: applyColumnChange }, + ...(guides ? { guides } : {}), + }); + alignMount(); + visible = true; + if (o.visible === false) hide(); + + if (o.vertical === true) { + showVRuler(); + } else if (o.verticalGutter === true) { + // Reserve the column up front so a later toggle doesn't reflow the + // content — and re-align, or the horizontal ruler keeps the old offset. + ensureVWrap(); + if (vmount) vmount.style.visibility = 'hidden'; + refresh(); + } + }; + + this.destroy = function destroy(): void { + ruler?.destroy(); + ruler = null; + guides?.destroy(); + guides = null; + vruler?.destroy(); + vruler = null; + if (vwrap) { + const el = editableEl(); + vwrap.parentElement?.insertBefore(el, vwrap); + vwrap.remove(); + vwrap = null; + vmount = null; + } + mount?.remove(); + mount = null; + visible = false; + vVisible = false; + }; + + // Summernote fires these on the context; keep the ruler in sync. + this.events = { + 'summernote.mouseup': refresh, + 'summernote.keyup': refresh, + 'summernote.change': refresh, + }; + }; +} + +export type { RulerChange, RulerChangePhase, RulerMetrics, RulerUnit }; diff --git a/packages/editor-ruler-summernote/test/plugin.test.ts b/packages/editor-ruler-summernote/test/plugin.test.ts new file mode 100644 index 0000000..6cab384 --- /dev/null +++ b/packages/editor-ruler-summernote/test/plugin.test.ts @@ -0,0 +1,222 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { defineRulerPlugin } from '../src/index'; + +/** + * Minimal stand-in for jQuery + $.summernote. Only the surface the adapter + * actually touches: `plugins`, `options`, `dom`, `extend`, and enough of the + * `ui` builder for the toolbar memo. + */ +function makeJQuery() { + const jq: any = (sel: any) => ({ + closest: () => ({ data: () => undefined }), + 0: sel, + }); + jq.extend = (deep: any, target: any, ...rest: any[]) => { + // supports both $.extend(target, ...) and $.extend(true, target, ...) + const objs = typeof deep === 'boolean' ? rest : [target, ...rest]; + const dst = typeof deep === 'boolean' ? target : deep; + for (const o of objs) for (const k of Object.keys(o ?? {})) dst[k] = { ...(dst[k] ?? {}), ...o[k] }; + return dst; + }; + jq.summernote = { + plugins: {}, + options: {}, + dom: { isPara: (n: any) => /^(P|H[1-6]|LI|BLOCKQUOTE|PRE|DIV)$/.test(n?.tagName ?? '') }, + ui: { + buttonGroup: (children: any[]) => ({ render: () => ({ children }) }), + button: (o: any) => o, + dropdown: (o: any) => o, + }, + }; + return jq; +} + +interface Ctx { + context: any; + editable: HTMLElement; + paragraph: HTMLElement; + afterCommand: ReturnType; +} + +function makeContext(rulerOptions: Record = {}): Ctx { + const host = document.createElement('div'); + const editable = document.createElement('div'); + editable.className = 'note-editable'; + Object.defineProperty(editable, 'clientWidth', { value: 600, configurable: true }); + const paragraph = document.createElement('p'); + paragraph.textContent = 'Hello ruler'; + editable.appendChild(paragraph); + host.appendChild(editable); + document.body.appendChild(host); + + const afterCommand = vi.fn(); + const context = { + layoutInfo: { editable: Object.assign([editable], { 0: editable }) }, + options: { ruler: { enabled: true, unit: 'cm', guides: true, guideSnap: 5, ...rulerOptions } }, + memo: vi.fn(), + invoke: (name: string) => { + if (name === 'editor.afterCommand') return afterCommand(); + if (name === 'editor.getLastRange') { + return { nodes: () => [paragraph] }; + } + return undefined; + }, + }; + return { context, editable, paragraph, afterCommand }; +} + +function mountPlugin(jq: any, ctx: Ctx) { + const Plugin = jq.summernote.plugins.ruler; + const instance: any = {}; + Plugin.call(instance, ctx.context); + instance.initialize?.(); + return instance; +} + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +describe('defineRulerPlugin (Summernote)', () => { + it('registers the plugin and merges default options', () => { + const jq = makeJQuery(); + defineRulerPlugin(jq); + expect(jq.summernote.plugins.ruler).toBeTypeOf('function'); + expect(jq.summernote.options.ruler).toMatchObject({ enabled: true, unit: 'cm', guides: true }); + }); + + it('is idempotent', () => { + const jq = makeJQuery(); + defineRulerPlugin(jq); + const first = jq.summernote.plugins.ruler; + defineRulerPlugin(jq); + expect(jq.summernote.plugins.ruler).toBe(first); + }); + + it('mounts the ruler above the editable', () => { + const jq = makeJQuery(); + defineRulerPlugin(jq); + const ctx = makeContext(); + mountPlugin(jq, ctx); + + const mount = document.querySelector('.edr-sn-mount') as HTMLElement; + expect(mount).toBeTruthy(); + expect(mount.querySelector('.edr-ruler')).toBeTruthy(); + expect(mount.nextElementSibling).toBe(ctx.editable); + }); + + it('applies indentation to the selected block and records one undo step', () => { + const jq = makeJQuery(); + defineRulerPlugin(jq); + const ctx = makeContext(); + mountPlugin(jq, ctx); + + const handle = document.querySelector('.edr-handle-left') as HTMLElement; + handle.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + + expect(ctx.paragraph.style.marginLeft).toBe('1px'); + expect(ctx.afterCommand).toHaveBeenCalledTimes(1); + }); + + it('a selection inside a table indents the whole table, never text-indent', () => { + const jq = makeJQuery(); + defineRulerPlugin(jq); + const ctx = makeContext(); + const table = document.createElement('table'); + table.innerHTML = ''; + ctx.editable.appendChild(table); + const cell = table.querySelector('td') as HTMLElement; + // Summernote hands back the cell; the adapter must climb to the table. + ctx.context.invoke = (name: string) => + name === 'editor.getLastRange' ? { nodes: () => [cell] } : ctx.afterCommand(); + mountPlugin(jq, ctx); + + const handle = document.querySelector('.edr-handle-left') as HTMLElement; + handle.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + + expect(table.style.marginLeft).toBe('1px'); + expect(table.style.textIndent).toBe(''); + expect(cell.style.marginLeft).toBe(''); + }); + + it('visible: false starts hidden but stays toggleable', () => { + const jq = makeJQuery(); + defineRulerPlugin(jq); + const ctx = makeContext({ visible: false }); + const api = mountPlugin(jq, ctx); + + const mount = document.querySelector('.edr-sn-mount') as HTMLElement; + expect(mount.style.display).toBe('none'); + expect(api.isVisible()).toBe(false); + + api.toggle(); + expect(mount.style.display).toBe(''); + expect(api.isVisible()).toBe(true); + }); + + it('vertical: true mounts the strip and wraps the editable', () => { + const jq = makeJQuery(); + defineRulerPlugin(jq); + const ctx = makeContext({ vertical: true }); + const api = mountPlugin(jq, ctx); + + const vwrap = document.querySelector('.edr-vwrap') as HTMLElement; + expect(vwrap).toBeTruthy(); + expect(vwrap.querySelector('.edr-vruler')).toBeTruthy(); + expect(vwrap.contains(ctx.editable)).toBe(true); + expect(api.isVRulerVisible()).toBe(true); + + api.toggleVRuler(); + expect((document.querySelector('.edr-sn-vmount') as HTMLElement).style.display).toBe('none'); + }); + + it('verticalGutter reserves the column across toggles', () => { + const jq = makeJQuery(); + defineRulerPlugin(jq); + const ctx = makeContext({ verticalGutter: true }); + const api = mountPlugin(jq, ctx); + + const vmount = document.querySelector('.edr-sn-vmount') as HTMLElement; + const mount = document.querySelector('.edr-sn-mount') as HTMLElement; + expect(vmount.style.visibility).toBe('hidden'); + expect(mount.style.paddingLeft).toBe('23px'); + + api.toggleVRuler(); + expect(vmount.style.visibility).toBe(''); + api.toggleVRuler(); + expect(vmount.style.visibility).toBe('hidden'); + expect(vmount.style.display).not.toBe('none'); + expect(mount.style.paddingLeft).toBe('23px'); + }); + + it('registers the ruler toolbar item', () => { + const jq = makeJQuery(); + defineRulerPlugin(jq); + const ctx = makeContext(); + mountPlugin(jq, ctx); + expect(ctx.context.memo).toHaveBeenCalledWith('button.ruler', expect.any(Function)); + }); + + it('destroy unwraps the editable and removes the mount', () => { + const jq = makeJQuery(); + defineRulerPlugin(jq); + const ctx = makeContext({ vertical: true }); + const api = mountPlugin(jq, ctx); + const host = ctx.editable.parentElement?.parentElement; + + api.destroy(); + expect(document.querySelector('.edr-sn-mount')).toBeNull(); + expect(document.querySelector('.edr-vwrap')).toBeNull(); + expect(host?.contains(ctx.editable)).toBe(true); + }); + + it('enabled: false keeps the plugin from initializing', () => { + const jq = makeJQuery(); + defineRulerPlugin(jq); + const ctx = makeContext({ enabled: false }); + const Plugin = jq.summernote.plugins.ruler; + const instance: any = {}; + Plugin.call(instance, ctx.context); + expect(instance.shouldInitialize()).toBe(false); + }); +}); diff --git a/packages/editor-ruler-summernote/tsconfig.json b/packages/editor-ruler-summernote/tsconfig.json new file mode 100644 index 0000000..7680f99 --- /dev/null +++ b/packages/editor-ruler-summernote/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "test"] +} diff --git a/packages/editor-ruler-summernote/tsup.config.ts b/packages/editor-ruler-summernote/tsup.config.ts new file mode 100644 index 0000000..f0d93e9 --- /dev/null +++ b/packages/editor-ruler-summernote/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs', 'iife'], + globalName: 'EditorRulerSummernote', + dts: true, + sourcemap: true, + clean: true, +}); diff --git a/packages/editor-ruler-summernote/vitest.config.ts b/packages/editor-ruler-summernote/vitest.config.ts new file mode 100644 index 0000000..647a9e5 --- /dev/null +++ b/packages/editor-ruler-summernote/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + }, +}); diff --git a/packages/editor-ruler-tiptap/package.json b/packages/editor-ruler-tiptap/package.json index 6f9784f..327e6cc 100644 --- a/packages/editor-ruler-tiptap/package.json +++ b/packages/editor-ruler-tiptap/package.json @@ -1,6 +1,6 @@ { "name": "@devslab/editor-ruler-tiptap", - "version": "1.0.1", + "version": "1.1.0", "description": "Tiptap extension for @devslab/editor-ruler — a Word-like horizontal ruler with margins, first-line indent, and guide lines.", "license": "Apache-2.0", "author": "devslab", diff --git a/packages/editor-ruler/README.ko.md b/packages/editor-ruler/README.ko.md index be872ab..1d171b2 100644 --- a/packages/editor-ruler/README.ko.md +++ b/packages/editor-ruler/README.ko.md @@ -30,7 +30,7 @@ ruler.destroy(); CDN 사용(빌드 도구 없이) — iife 빌드가 전역 `EditorRuler`를 노출합니다: ```html - + ``` 에디터 연동은 어댑터를 사용하세요 (예: [`@devslab/editor-ruler-froala`](https://github.com/devslab-kr/editor-ruler/tree/main/packages/editor-ruler-froala)). diff --git a/packages/editor-ruler/README.md b/packages/editor-ruler/README.md index 9cb4f6e..7155aa6 100644 --- a/packages/editor-ruler/README.md +++ b/packages/editor-ruler/README.md @@ -30,7 +30,7 @@ Handles are keyboard-accessible sliders (`←`/`→`, `Shift` ×10, `Home`/`End` CDN usage (no build tools) — the iife build exposes an `EditorRuler` global: ```html - + ``` For editor integrations see the adapters (e.g. [`@devslab/editor-ruler-froala`](https://github.com/devslab-kr/editor-ruler/tree/main/packages/editor-ruler-froala)). diff --git a/packages/editor-ruler/package.json b/packages/editor-ruler/package.json index 39dbe62..69cc955 100644 --- a/packages/editor-ruler/package.json +++ b/packages/editor-ruler/package.json @@ -1,6 +1,6 @@ { "name": "@devslab/editor-ruler", - "version": "1.0.1", + "version": "1.1.0", "description": "A Word-like horizontal ruler for web rich-text editors: margins, first-line indent, drag handles. Editor-agnostic core.", "license": "Apache-2.0", "author": "devslab", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7aa052..8b49419 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,15 @@ importers: specifier: '>=4.0.0' version: 5.3.1 + packages/editor-ruler-summernote: + dependencies: + '@devslab/editor-ruler': + specifier: workspace:^ + version: link:../editor-ruler + summernote: + specifier: '>=0.8.0' + version: 0.9.1 + packages/editor-ruler-tiptap: dependencies: '@devslab/editor-ruler': @@ -1928,6 +1937,10 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + summernote@0.9.1: + resolution: {integrity: sha512-5Hfuey6+N0XIbk8ZpkGEVDmrkRVRHZWKBhw/072i9/TJLaWUKboB+KOyyd6AzDP2CQs1O6or4zRTU8HY30kt4w==} + engines: {node: '>=17.0.0'} + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -4686,6 +4699,8 @@ snapshots: tinyglobby: 0.2.17 ts-interface-checker: 0.1.13 + summernote@0.9.1: {} + symbol-tree@3.2.4: {} term-size@2.2.1: {} diff --git a/scripts/build-pages.mjs b/scripts/build-pages.mjs index e5dbc39..f52b484 100644 --- a/scripts/build-pages.mjs +++ b/scripts/build-pages.mjs @@ -34,6 +34,10 @@ await cp( path.join(root, 'packages/editor-ruler-ckeditor5/dist/index.js'), path.join(outDir, 'vendor', 'editor-ruler-ckeditor5.mjs'), ); +await cp( + path.join(root, 'packages/editor-ruler-summernote/dist/index.js'), + path.join(outDir, 'vendor', 'editor-ruler-summernote.mjs'), +); const indexPath = path.join(outDir, 'index.html'); const index = await readFile(indexPath, 'utf8'); diff --git a/site/index.html b/site/index.html index ea18929..e58c00e 100644 --- a/site/index.html +++ b/site/index.html @@ -262,6 +262,7 @@

라이브 데모 — 에디터별로 보기

+ @@ -315,6 +316,21 @@

라이브 데모 — 에디터별로 보기

CKEditor 5 모듈을 CDN에서 불러옵니다 — 네트워크에 따라 잠시 걸릴 수 있습니다. 이미지는 CKEditor 이미지 플러그인 구성에 따라 동일하게 동작합니다.

+ +

cell