From 224c545fe3b7f3146db55d2fd2ab166a8909c0f3 Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Sun, 9 Aug 2026 17:34:40 +0900 Subject: [PATCH] =?UTF-8?q?feat(summernote):=20new=20adapter=20=E2=80=94?= =?UTF-8?q?=20the=20second=20one=20with=20table=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summernote pulls ~118k npm downloads a week and has no ruler; it is also jQuery-based direct-DOM, which makes it the cheapest remaining adapter because the Froala implementation transfers almost whole. Researched the host APIs from Summernote's source rather than guessing, because the undo guarantee depended on it: editor.afterCommand() is the method that calls history.recordUndo(), so routing commits through it gives us one undo step per drag gesture, the same contract every other adapter honors. Blocks come from editor.getLastRange().nodes(dom.isPara) with a native-selection fallback for before Summernote has a range. Because it is direct-DOM, whole-table indent and column-width markers come along — Summernote is the first adapter besides Froala to have them, so that capability is no longer Froala-only. Options sit under the "ruler" key (enabled/visible/unit/vertical/ verticalGutter/guides/guideSnap/language), matching the namespaced style of the Tiptap and CKEditor 5 adapters rather than Froala's flat prefixes. Docs updated everywhere the adapter set is stated: both root READMEs (packages table, quick start, StackBlitz list), package READMEs en/ko, ARCHITECTURE (file map, plus §6 now points at this adapter as the worked example of the 'adding an editor' walkthrough), the landing page (demo tab with lazy CDN loading, integration section, FAQ), and CHANGELOG. Sentences that counted adapters ('all three', 'the four packages') were either de-counted or corrected. Verified in a real browser against real Summernote, not just jsdom: ruler mounts into its toolbar, dragging moves a paragraph 60px -> 70px and exports plain inline CSS, undo restores in exactly one step, column markers appear with the caret in a table cell, and the vertical-ruler gutter holds the content still (0px shift) across a toggle. 110 unit tests green; one of the new tests caught a real bug — the gutter-only path skipped refresh(), so the horizontal ruler kept a stale offset. --- CHANGELOG.md | 33 +- README.ko.md | 32 +- README.md | 32 +- docs/ARCHITECTURE.md | 8 + examples/ckeditor5/package.json | 2 +- examples/froala/package.json | 2 +- examples/summernote/index.html | 18 + examples/summernote/main.js | 36 ++ examples/summernote/package.json | 22 + examples/tiptap/package.json | 2 +- examples/vanilla/package.json | 2 +- packages/editor-ruler-ckeditor5/package.json | 2 +- packages/editor-ruler-froala/README.ko.md | 2 +- packages/editor-ruler-froala/README.md | 2 +- packages/editor-ruler-froala/package.json | 2 +- packages/editor-ruler-summernote/README.ko.md | 45 ++ packages/editor-ruler-summernote/README.md | 45 ++ packages/editor-ruler-summernote/package.json | 60 ++ packages/editor-ruler-summernote/src/index.ts | 533 ++++++++++++++++++ .../test/plugin.test.ts | 222 ++++++++ .../editor-ruler-summernote/tsconfig.json | 4 + .../editor-ruler-summernote/tsup.config.ts | 10 + .../editor-ruler-summernote/vitest.config.ts | 7 + packages/editor-ruler-tiptap/package.json | 2 +- packages/editor-ruler/README.ko.md | 2 +- packages/editor-ruler/README.md | 2 +- packages/editor-ruler/package.json | 2 +- pnpm-lock.yaml | 15 + scripts/build-pages.mjs | 4 + site/index.html | 131 ++++- 30 files changed, 1252 insertions(+), 29 deletions(-) create mode 100644 examples/summernote/index.html create mode 100644 examples/summernote/main.js create mode 100644 examples/summernote/package.json create mode 100644 packages/editor-ruler-summernote/README.ko.md create mode 100644 packages/editor-ruler-summernote/README.md create mode 100644 packages/editor-ruler-summernote/package.json create mode 100644 packages/editor-ruler-summernote/src/index.ts create mode 100644 packages/editor-ruler-summernote/test/plugin.test.ts create mode 100644 packages/editor-ruler-summernote/tsconfig.json create mode 100644 packages/editor-ruler-summernote/tsup.config.ts create mode 100644 packages/editor-ruler-summernote/vitest.config.ts 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