@growgroup/visual-editor 0.4.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +92 -0
- package/dist/editor.css +1 -1
- package/package.json +7 -2
- package/src/editor/FrontendVisualEditor.tsx +244 -17
- package/src/editor/collab/CollabPresence.tsx +172 -0
- package/src/editor/collab/binding.ts +269 -0
- package/src/editor/collab/color.ts +28 -0
- package/src/editor/collab/connection.ts +110 -0
- package/src/editor/collab/layer.ts +168 -0
- package/src/editor/collab/merge.ts +112 -0
- package/src/editor/collab/morph.ts +113 -0
- package/src/editor/collab/room.ts +82 -0
- package/src/editor/collab/runtime.ts +442 -0
- package/src/editor/collab/signals.ts +11 -0
- package/src/editor/collab/store.ts +192 -0
- package/src/editor/collab/useCollab.ts +266 -0
- package/src/editor/components/AiPromptPopover.tsx +3 -2
- package/src/editor/components/EditorCanvas.tsx +27 -0
- package/src/editor/components/EditorContextMenu.tsx +80 -0
- package/src/editor/components/EditorPropertyPanel.tsx +248 -80
- package/src/editor/components/EditorSidebar.tsx +2 -2
- package/src/editor/components/multi-page/MultiPageCanvasView.tsx +41 -19
- package/src/editor/components/ppt/PptChrome.tsx +46 -4
- package/src/editor/components/ppt/PptComments.tsx +3 -8
- package/src/editor/components/property-panel/VariableAwareSizeInput.tsx +94 -50
- package/src/editor/components/property-panel/VariableAwareUnitInput.tsx +12 -6
- package/src/editor/components/shell/EditorTopBar.tsx +13 -1
- package/src/editor/components/shell/PagesPanel.tsx +3 -0
- package/src/editor/constants.ts +24 -0
- package/src/editor/contexts/EditorAppearanceContext.tsx +5 -5
- package/src/editor/contexts/EditorHistoryContext.tsx +2 -0
- package/src/editor/contexts/MultiPageCanvasContext.tsx +120 -15
- package/src/editor/hooks/index.ts +3 -0
- package/src/editor/hooks/useDragResize.ts +30 -5
- package/src/editor/hooks/useElementActions.ts +41 -9
- package/src/editor/hooks/useElementSelection.ts +4 -2
- package/src/editor/hooks/useGoogleFonts.ts +6 -8
- package/src/editor/hooks/useIframeSetup.ts +10 -1
- package/src/editor/hooks/useInlineTextSelection.ts +16 -0
- package/src/editor/hooks/useKeepArtboardInPlace.ts +87 -0
- package/src/editor/hooks/useLinkNavigation.ts +160 -0
- package/src/editor/hooks/useResizablePanel.ts +3 -2
- package/src/editor/parts.ts +2 -0
- package/src/editor/types.ts +4 -0
- package/src/editor/utils/debug.ts +2 -5
- package/src/editor/utils/dom-utils.ts +45 -0
- package/src/editor/utils/drop-target.ts +1 -1
- package/src/editor/utils/flex-reorder.ts +1 -1
- package/src/editor/utils/free-layout-tree.ts +562 -0
- package/src/editor/utils/free-layout.ts +794 -0
- package/src/editor/utils/geometry.ts +41 -3
- package/src/editor/utils/html-utils.ts +4 -1
- package/src/editor/utils/inline-text-style.ts +426 -0
- package/src/editor/utils/link-navigation.ts +474 -0
- package/src/editor/utils/restore-flow.ts +1 -1
- package/src/editor/utils/smart-guides.ts +1 -1
- package/src/editor/utils/storage.ts +47 -0
- package/src/editor/utils/style-utils.ts +24 -0
- package/src/editor/utils/tailwind-utils.ts +12 -0
- package/src/editor/utils/text-line-break.ts +121 -0
- package/src/index.ts +15 -0
- package/src/io.ts +29 -0
- package/src/styles/skin.css +17 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* テキスト編集中の Enter を「同じ要素の中の改行(<br>)」にする。
|
|
3
|
+
*
|
|
4
|
+
* [なぜ要るか]
|
|
5
|
+
* contenteditable の既定の Enter は段落を分ける。Chrome は <p> の中に <div> を差し込み
|
|
6
|
+
* (`<p>前半<div>後半</div></p>`)、HTML では p の中に div を置けないので、保存して読み直すと
|
|
7
|
+
* 段落が閉じ、後半が段落のクラス(文字の大きさ・行間)を持たない div になっていた。
|
|
8
|
+
* Shift+Enter は同じ要素の中に <br> が入るだけで正しいので、Enter もそれにそろえる。
|
|
9
|
+
* p / 見出し / li / td / ボタン / span など、テキスト編集に入るすべての要素で同じ。
|
|
10
|
+
*
|
|
11
|
+
* [なぜ execCommand('insertLineBreak') か]
|
|
12
|
+
* Shift+Enter と同じブラウザの処理なので、範囲選択の削除・行末での改行(次の行を作る 2 つ目の <br>)・
|
|
13
|
+
* 文字単位の取り消し(編集中の ⌘Z 1 回)・input イベント(共同編集の送信)がそのまま付いてくる。
|
|
14
|
+
* 自前で Range に <br> を差し込むと、ブラウザの取り消しの履歴に載らず ⌘Z で戻らない。
|
|
15
|
+
* 使えないブラウザ(insertLineBreak を持たない)でだけ自前で差し込む。
|
|
16
|
+
*
|
|
17
|
+
* [日本語入力]
|
|
18
|
+
* 変換を確定する Enter は改行ではない。ここを誤ると確定するたびに改行が入る。
|
|
19
|
+
* Chrome は確定の keydown に isComposing=true / keyCode 229 を付け、Safari は compositionend の
|
|
20
|
+
* 直後に keyCode 229 の keydown を送る。確定の直後(keyup まで)の Enter も見送る。
|
|
21
|
+
* 見送った Enter がそれでも段落を分けようとしたとき(beforeinput の insertParagraph)は、そこで改行に直す。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const EDITING_HOST = '[contenteditable="true"]';
|
|
25
|
+
|
|
26
|
+
function editingHostOf(target: EventTarget | null): HTMLElement | null {
|
|
27
|
+
const el = target as Element | null;
|
|
28
|
+
if (!el || typeof el.closest !== 'function') return null;
|
|
29
|
+
return el.closest<HTMLElement>(EDITING_HOST);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 改行の後ろに、次の行を作る中身(文字・画像・改行)があるか */
|
|
33
|
+
function hasContentAfter(doc: Document, node: Node, host: HTMLElement): boolean {
|
|
34
|
+
const range = doc.createRange();
|
|
35
|
+
range.setStartAfter(node);
|
|
36
|
+
range.setEnd(host, host.childNodes.length);
|
|
37
|
+
const rest = range.cloneContents();
|
|
38
|
+
if ((rest.textContent ?? '').length > 0) return true;
|
|
39
|
+
return !!rest.querySelector('br, img, svg, video, iframe, input, canvas');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** insertLineBreak を持たないブラウザ向け。選択を消して <br> を入れ、キャレットを次の行に置く */
|
|
43
|
+
function insertLineBreakManually(doc: Document, host: HTMLElement): void {
|
|
44
|
+
const selection = doc.getSelection();
|
|
45
|
+
if (!selection || selection.rangeCount === 0) return;
|
|
46
|
+
const range = selection.getRangeAt(0);
|
|
47
|
+
if (!host.contains(range.commonAncestorContainer)) return;
|
|
48
|
+
range.deleteContents();
|
|
49
|
+
const br = doc.createElement('br');
|
|
50
|
+
range.insertNode(br);
|
|
51
|
+
// 行末の <br> 1 つだけでは次の行ができない(キャレットが前の行に残る)ので、もう 1 つ置く
|
|
52
|
+
if (!hasContentAfter(doc, br, host)) br.after(doc.createElement('br'));
|
|
53
|
+
const caret = doc.createRange();
|
|
54
|
+
caret.setStartAfter(br);
|
|
55
|
+
caret.collapse(true);
|
|
56
|
+
selection.removeAllRanges();
|
|
57
|
+
selection.addRange(caret);
|
|
58
|
+
const InputEventCtor = doc.defaultView?.InputEvent ?? InputEvent;
|
|
59
|
+
host.dispatchEvent(new InputEventCtor('input', { bubbles: true, inputType: 'insertLineBreak' }));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function insertLineBreak(doc: Document, host: HTMLElement): void {
|
|
63
|
+
let done = false;
|
|
64
|
+
try {
|
|
65
|
+
done = doc.execCommand('insertLineBreak');
|
|
66
|
+
} catch {
|
|
67
|
+
done = false;
|
|
68
|
+
}
|
|
69
|
+
if (!done) insertLineBreakManually(doc, host);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* iframe の紙面に「Enter = 改行」を張る。編集に入る経路(ダブルクリック・テキストツール)によらず、
|
|
74
|
+
* 編集中の要素(contenteditable="true")の中で押された Enter だけを扱う。
|
|
75
|
+
* @returns 外す関数
|
|
76
|
+
*/
|
|
77
|
+
export function setupLineBreakOnEnter(doc: Document): () => void {
|
|
78
|
+
// 変換の確定から keyup までの間(Safari は確定の後に Enter の keydown が来る)
|
|
79
|
+
let composingEnter = false;
|
|
80
|
+
|
|
81
|
+
const onCompositionEnd = () => {
|
|
82
|
+
composingEnter = true;
|
|
83
|
+
};
|
|
84
|
+
const onKeyUp = () => {
|
|
85
|
+
composingEnter = false;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
89
|
+
// 日本語入力の変換中・確定の Enter には触らない(最初に見る)
|
|
90
|
+
if (e.isComposing || e.keyCode === 229) return;
|
|
91
|
+
if (e.key !== 'Enter') return;
|
|
92
|
+
if (composingEnter) return;
|
|
93
|
+
// Shift+Enter はブラウザの既定がすでに改行。⌘ / Ctrl との組み合わせはショートカットに譲る
|
|
94
|
+
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
|
|
95
|
+
if (e.defaultPrevented) return;
|
|
96
|
+
const host = editingHostOf(e.target);
|
|
97
|
+
if (!host) return;
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
insertLineBreak(doc, host);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// 上で見送った Enter や、キーを経ない段落分け(Android の IME など)も改行にする
|
|
103
|
+
const onBeforeInput = (e: InputEvent) => {
|
|
104
|
+
if (e.inputType !== 'insertParagraph' || e.isComposing) return;
|
|
105
|
+
const host = editingHostOf(e.target);
|
|
106
|
+
if (!host) return;
|
|
107
|
+
e.preventDefault();
|
|
108
|
+
insertLineBreak(doc, host);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
doc.addEventListener('compositionend', onCompositionEnd, true);
|
|
112
|
+
doc.addEventListener('keyup', onKeyUp, true);
|
|
113
|
+
doc.addEventListener('keydown', onKeyDown, true);
|
|
114
|
+
doc.addEventListener('beforeinput', onBeforeInput as EventListener, true);
|
|
115
|
+
return () => {
|
|
116
|
+
doc.removeEventListener('compositionend', onCompositionEnd, true);
|
|
117
|
+
doc.removeEventListener('keyup', onKeyUp, true);
|
|
118
|
+
doc.removeEventListener('keydown', onKeyDown, true);
|
|
119
|
+
doc.removeEventListener('beforeinput', onBeforeInput as EventListener, true);
|
|
120
|
+
};
|
|
121
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -60,7 +60,22 @@ export type {
|
|
|
60
60
|
EditorPartDef,
|
|
61
61
|
EditorPartCategory,
|
|
62
62
|
EditorPartsLibrary,
|
|
63
|
+
EditorCollab,
|
|
64
|
+
EditorCollabUser,
|
|
63
65
|
} from "./io";
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 共同編集の居場所(io.collab を渡したときだけ中身が入る)。
|
|
69
|
+
* 利用側のページ一覧などに「誰がどのページに居るか」を出すのに使う
|
|
70
|
+
*/
|
|
71
|
+
export { useCollabPresence } from "./editor/collab/store";
|
|
72
|
+
export type { CollabPresence, CollabPeer, CollabStatus } from "./editor/collab/store";
|
|
73
|
+
/**
|
|
74
|
+
* 部屋名が中継(gg-manager の hocuspocus-server)に保存される形かの検査。
|
|
75
|
+
* 形を外れた部屋は中継はされるが保存されないので、殻・書き戻し役でも繋ぐ前にこれで確かめる
|
|
76
|
+
*/
|
|
77
|
+
export { checkCollabRoom } from "./editor/collab/room";
|
|
78
|
+
export type { CollabRoomCheck } from "./editor/collab/room";
|
|
64
79
|
export type { CSSVariableDefinition, CSSVariableCategory } from "./types/css-variables";
|
|
65
80
|
|
|
66
81
|
/**
|
package/src/io.ts
CHANGED
|
@@ -196,6 +196,35 @@ export type EditorIO = {
|
|
|
196
196
|
loadVariables?: () => Promise<CSSVariableDefinition[]>;
|
|
197
197
|
/** CSS 変数の保存。無ければブラウザ内(localStorage)に持つ */
|
|
198
198
|
saveVariables?: (variables: CSSVariableDefinition[]) => Promise<void>;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* リアルタイム共同編集(Figma 風)。Hocuspocus の部屋に本文と居場所を載せる。
|
|
202
|
+
* 無ければ今までどおり(共同編集の UI も出さず、接続もしない)。
|
|
203
|
+
* あるページの部屋に入っている間は onSave の自動保存を呼ばない(ファイルに書くのは書き戻し役)
|
|
204
|
+
*/
|
|
205
|
+
collab?: EditorCollab;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/** 共同編集の参加者。color が無ければ id から決める */
|
|
209
|
+
export type EditorCollabUser = { id: string; name: string; color?: string };
|
|
210
|
+
|
|
211
|
+
export type EditorCollab = {
|
|
212
|
+
/** Hocuspocus の URL(wss://…) */
|
|
213
|
+
url: string;
|
|
214
|
+
/** contentId → ページの部屋名。null ならそのページは共同編集しない */
|
|
215
|
+
roomFor: (contentId: string) => string | null;
|
|
216
|
+
/** 案件の部屋名(居場所・pages) */
|
|
217
|
+
projectRoom: string;
|
|
218
|
+
/** contentId → route(案件の部屋の awareness と pages のキー) */
|
|
219
|
+
routeFor: (contentId: string) => string | null;
|
|
220
|
+
user: EditorCollabUser;
|
|
221
|
+
token?: () => Promise<string | undefined>;
|
|
222
|
+
/** getCleanHtml の結果 → 共有する本文。無ければそのまま */
|
|
223
|
+
encode?: (cleanHtml: string) => string;
|
|
224
|
+
/** 共有する本文 → エディタに渡す HTML。無ければそのまま */
|
|
225
|
+
decode?: (shared: string, contentId: string) => string;
|
|
226
|
+
/** true: 書き戻し役が居ないとき「変更は保存されません」を出し続ける(静的なエディタ) */
|
|
227
|
+
requireBridge?: boolean;
|
|
199
228
|
};
|
|
200
229
|
|
|
201
230
|
let current: EditorIO = {};
|
package/src/styles/skin.css
CHANGED
|
@@ -240,6 +240,23 @@
|
|
|
240
240
|
.gg-editor-ui .ed-save-status[data-save-status="dirty"] { color: var(--ed-warning); }
|
|
241
241
|
.gg-editor-ui .ed-save-status[data-save-status="error"] { color: var(--ed-danger); }
|
|
242
242
|
.gg-editor-ui .ed-save-status[data-save-status="saved"] svg { color: var(--ed-success); }
|
|
243
|
+
/* 共同編集(io.collab): 参加者のアバター・書き戻し先・接続状態・ページの点 */
|
|
244
|
+
.gg-editor-ui .ed-collab-presence { display: inline-flex; align-items: center; gap: 10px; }
|
|
245
|
+
.gg-editor-ui .ed-collab-avatars { display: inline-flex; align-items: center; padding-left: 4px; }
|
|
246
|
+
.gg-editor-ui .ed-collab-avatar { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; margin-left: -4px; padding: 0; border-radius: 50%; border: 2px solid var(--ed-panel); color: #fff; font-size: 11px; font-weight: 700; line-height: 1; cursor: pointer; flex-shrink: 0; }
|
|
247
|
+
.gg-editor-ui .ed-collab-avatar:hover { transform: translateY(-1px); }
|
|
248
|
+
.gg-editor-ui .ed-collab-avatar-self { cursor: default; margin-left: 4px; box-shadow: 0 0 0 1px var(--ed-line-strong); }
|
|
249
|
+
.gg-editor-ui .ed-collab-avatar-self:hover, .gg-editor-ui .ed-collab-avatar-more:hover { transform: none; }
|
|
250
|
+
.gg-editor-ui .ed-collab-avatar-more { background: var(--ed-panel-raised); color: var(--ed-text); cursor: default; }
|
|
251
|
+
.gg-editor-ui .ed-collab-bridge { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; white-space: nowrap; color: var(--ed-muted); }
|
|
252
|
+
.gg-editor-ui .ed-collab-bridge-warn { color: var(--ed-warning); }
|
|
253
|
+
.gg-editor-ui .ed-collab-bridge-alert { color: #fff; background: var(--ed-danger); padding: 3px 8px; border-radius: var(--ed-radius); font-weight: 600; }
|
|
254
|
+
.gg-editor-ui .ed-save-status[data-collab-status="synced"] svg { color: var(--ed-success); }
|
|
255
|
+
.gg-editor-ui .ed-save-status[data-collab-status="offline"] { color: var(--ed-danger); }
|
|
256
|
+
.gg-editor-ui .ed-save-status[data-collab-status="error"] { color: var(--ed-danger); font-weight: 600; }
|
|
257
|
+
.gg-editor-ui .ed-save-status[data-collab-pending] { color: var(--ed-warning); }
|
|
258
|
+
.gg-editor-ui .ed-collab-dots { display: inline-flex; align-items: center; gap: 2px; flex-shrink: 0; }
|
|
259
|
+
.gg-editor-ui .ed-collab-dot { width: 7px; height: 7px; border-radius: 50%; box-shadow: 0 0 0 1px var(--ed-panel); }
|
|
243
260
|
.gg-editor-ui .ed-panel-heading { height: 48px; display: flex; align-items: center; gap: 8px; padding: 0 16px; border-bottom: 1px solid var(--ed-line); flex-shrink: 0; font-size: 14px; font-weight: 600; }
|
|
244
261
|
.gg-editor-ui .ed-toolbar { border: 1px solid var(--ed-line); border-radius: 12px; padding: 8px; background: var(--ed-panel-raised); color: var(--ed-text); box-shadow: var(--ed-shadow); }
|
|
245
262
|
.gg-editor-ui .ed-footer { height: 36px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 16px; border-top: 1px solid var(--ed-line); background: var(--ed-panel); color: var(--ed-muted); font-size: 12px; flex-shrink: 0; }
|
package/src/version.ts
CHANGED