@growgroup/visual-editor 0.3.0 → 0.3.1

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.
@@ -34,7 +34,7 @@ import React, {
34
34
  useState,
35
35
  useSyncExternalStore,
36
36
  } from 'react';
37
- import { useEditorArtboard } from './EditorArtboardContext';
37
+ import { useEditorArtboard, computeContentDepths, type DocumentAttributes } from './EditorArtboardContext';
38
38
  import { useEditorTool } from './EditorToolContext';
39
39
  import { useEditorView } from './EditorViewContext';
40
40
  import { SLIDE_HEIGHT, SLIDE_WIDTH, WEBPAGE_WIDTH } from '../constants';
@@ -82,7 +82,7 @@ const WRAP_AT: Record<EditorMode, (count: number) => number> = {
82
82
  export interface PageFrame {
83
83
  id: string;
84
84
  title: string;
85
- /** 本文。null = まだ読んでいない */
85
+ /** 本文。null = まだ読んでいない(または読み直す) */
86
86
  html: string | null;
87
87
  /** 本文を読んでいる途中 */
88
88
  loading: boolean;
@@ -93,6 +93,19 @@ export interface PageFrame {
93
93
  measured: boolean;
94
94
  /** 未保存の変更がある(生きているページだけ立つ) */
95
95
  isDirty: boolean;
96
+ /** 親ページ(contentList[].parentId。一覧に無い親は null 扱い) */
97
+ parentId: string | null;
98
+ /** 階層の深さ(ルート = 0) */
99
+ depth: number;
100
+ /** 編集しない表示の URL(contentList[].href) */
101
+ href?: string;
102
+ /** 利用側が進める本文の版(contentList[].revision)。変わったら読み直す */
103
+ revision: number;
104
+ /**
105
+ * 本文が古い(部品の反映などで外から変わった)。読み直すときは contentList[].thumbnailHtml
106
+ * ではなく io.loadContent から取る(thumbnailHtml は利用側が最初に渡した姿のままなので)
107
+ */
108
+ stale: boolean;
96
109
  }
97
110
 
98
111
  /** レイアウト済み(位置付き)のフレーム */
@@ -101,6 +114,9 @@ export interface PageFrameLayout extends PageFrame {
101
114
  index: number;
102
115
  }
103
116
 
117
+ /** フレームの並べ方。tree = 階層(parentId)のツリー、grid = 行で折り返す */
118
+ export type CanvasLayoutKind = 'grid' | 'tree';
119
+
104
120
  export interface CanvasViewState {
105
121
  canvasOffset: { x: number; y: number };
106
122
  canvasZoom: number;
@@ -129,13 +145,19 @@ export interface MultiPageCanvasContextValue {
129
145
  // ---- ページフレーム
130
146
  pages: PageFrameLayout[];
131
147
  bounds: CanvasBounds;
148
+ /** 並べ方(contentList に階層があれば tree) */
149
+ layout: CanvasLayoutKind;
132
150
  getPage: (id: string) => PageFrameLayout | undefined;
133
- updatePageFrame: (
134
- id: string,
135
- updates: Partial<Pick<PageFrame, 'title' | 'html' | 'size' | 'isDirty' | 'measured' | 'loading' | 'error'>>,
136
- ) => void;
151
+ updatePageFrame: (id: string, updates: Partial<Omit<PageFrame, 'id'>>) => void;
137
152
  /** 本文を差し替える(保存後・読み込み後) */
138
153
  setPageHtml: (id: string, html: string) => void;
154
+ /**
155
+ * 見るだけの紙面の本文を捨てて読み直させる(部品の定義を更新して他ページが変わったとき等)。
156
+ * except のページ(編集中のページ)は触らない。読み直す手段(io.loadContent)が無ければ何もしない
157
+ */
158
+ invalidatePages: (options?: { except?: readonly (string | null | undefined)[] }) => void;
159
+ /** 文書(`<html>`)に付ける属性(利用側の documentAttributes)。紙面にも読み直さずに反映する */
160
+ documentAttributes: DocumentAttributes;
139
161
  /**
140
162
  * 高さの実測を反映する。source が 'preview' のときは、生きているページには効かない
141
163
  * (エディタの実測が正。見るだけの紙面はフォントや画像の読み込み前に測ることがある)
@@ -241,16 +263,19 @@ export function useCanvasViewStateOptional(): CanvasViewState | null {
241
263
  // レイアウト
242
264
  // ============================================================
243
265
 
244
- /**
245
- * 寸法から位置を決める。行ごとに折り返し、行の高さはその行で一番高いフレームに合わせる。
246
- * 位置を状態に持たないので、高さが実測で変わっても流れ直すだけで済む
247
- */
248
- export function layoutFrames(
249
- frames: PageFrame[],
250
- editorMode: EditorMode,
251
- ): { pages: PageFrameLayout[]; bounds: CanvasBounds } {
252
- const gap = GAP[editorMode];
253
- const perRow = WRAP_AT[editorMode](frames.length);
266
+ function boundsOf(pages: PageFrameLayout[]): CanvasBounds {
267
+ return pages.length
268
+ ? {
269
+ minX: Math.min(...pages.map((p) => p.position.x)),
270
+ minY: Math.min(...pages.map((p) => p.position.y)),
271
+ maxX: Math.max(...pages.map((p) => p.position.x + p.size.width)),
272
+ maxY: Math.max(...pages.map((p) => p.position.y + p.size.height)),
273
+ }
274
+ : { minX: 0, minY: 0, maxX: 0, maxY: 0 };
275
+ }
276
+
277
+ /** 行で折り返す並べ方(階層が無いとき)。行の高さはその行で一番高いフレームに合わせる */
278
+ function layoutGrid(frames: PageFrame[], gap: { x: number; y: number }, perRow: number): PageFrameLayout[] {
254
279
  const pages: PageFrameLayout[] = [];
255
280
  let x = 0;
256
281
  let y = 0;
@@ -265,15 +290,101 @@ export function layoutFrames(
265
290
  x += frame.size.width + gap.x;
266
291
  rowHeight = Math.max(rowHeight, frame.size.height);
267
292
  });
268
- const bounds: CanvasBounds = pages.length
269
- ? {
270
- minX: Math.min(...pages.map((p) => p.position.x)),
271
- minY: Math.min(...pages.map((p) => p.position.y)),
272
- maxX: Math.max(...pages.map((p) => p.position.x + p.size.width)),
273
- maxY: Math.max(...pages.map((p) => p.position.y + p.size.height)),
274
- }
275
- : { minX: 0, minY: 0, maxX: 0, maxY: 0 };
276
- return { pages, bounds };
293
+ return pages;
294
+ }
295
+
296
+ /**
297
+ * 階層(parentId)のツリーで並べる(サイトマップの図と同じ姿)。
298
+ * - 深さごとに行を作り、行の高さはその深さで一番高いフレームに合わせる
299
+ * - 親は子の並び(部分木)の中央の上に置く。部分木の幅 = max(自分の幅, 子の部分木の幅の和 + 間隔)
300
+ * - 親が一覧に無い・循環しているものはルートとして左から順に置く
301
+ * 一覧の順(index)は変えない(フレーム名の番号・読み込みの順番待ちに使う)
302
+ */
303
+ function layoutTree(frames: PageFrame[], gap: { x: number; y: number }): PageFrameLayout[] {
304
+ const byId = new Map(frames.map((f) => [f.id, f] as const));
305
+ const children = new Map<string, PageFrame[]>();
306
+ const roots: PageFrame[] = [];
307
+ // 循環の検出: 親をたどってルートに着くか(着かないものはルート扱い)
308
+ const reachesRoot = (f: PageFrame): boolean => {
309
+ const seen = new Set<string>([f.id]);
310
+ let cur: PageFrame | undefined = f;
311
+ while (cur?.parentId && byId.has(cur.parentId)) {
312
+ if (seen.has(cur.parentId)) return false;
313
+ seen.add(cur.parentId);
314
+ cur = byId.get(cur.parentId);
315
+ }
316
+ return true;
317
+ };
318
+ frames.forEach((f) => {
319
+ const parent = f.parentId && f.parentId !== f.id && byId.has(f.parentId) && reachesRoot(f) ? f.parentId : null;
320
+ if (parent) {
321
+ const list = children.get(parent) ?? [];
322
+ list.push(f);
323
+ children.set(parent, list);
324
+ } else {
325
+ roots.push(f);
326
+ }
327
+ });
328
+ const subtreeWidth = new Map<string, number>();
329
+ const widthOf = (f: PageFrame): number => {
330
+ const cached = subtreeWidth.get(f.id);
331
+ if (cached != null) return cached;
332
+ const kids = children.get(f.id) ?? [];
333
+ const kidsWidth = kids.reduce((sum, k, i) => sum + widthOf(k) + (i > 0 ? gap.x : 0), 0);
334
+ const w = Math.max(f.size.width, kidsWidth);
335
+ subtreeWidth.set(f.id, w);
336
+ return w;
337
+ };
338
+ // 深さと行の高さ
339
+ const depthOf = new Map<string, number>();
340
+ const rowHeight: number[] = [];
341
+ const walk = (f: PageFrame, d: number) => {
342
+ depthOf.set(f.id, d);
343
+ rowHeight[d] = Math.max(rowHeight[d] ?? 0, f.size.height);
344
+ (children.get(f.id) ?? []).forEach((k) => walk(k, d + 1));
345
+ };
346
+ roots.forEach((r) => walk(r, 0));
347
+ const rowY: number[] = [];
348
+ rowHeight.forEach((h, d) => {
349
+ rowY[d] = d === 0 ? 0 : rowY[d - 1] + rowHeight[d - 1] + gap.y;
350
+ });
351
+ const position = new Map<string, { x: number; y: number }>();
352
+ const place = (f: PageFrame, x0: number) => {
353
+ const d = depthOf.get(f.id) ?? 0;
354
+ const w = widthOf(f);
355
+ position.set(f.id, { x: x0 + (w - f.size.width) / 2, y: rowY[d] ?? 0 });
356
+ const kids = children.get(f.id) ?? [];
357
+ const kidsWidth = kids.reduce((sum, k, i) => sum + widthOf(k) + (i > 0 ? gap.x : 0), 0);
358
+ let cx = x0 + (w - kidsWidth) / 2;
359
+ kids.forEach((k) => {
360
+ place(k, cx);
361
+ cx += widthOf(k) + gap.x;
362
+ });
363
+ };
364
+ let x = 0;
365
+ roots.forEach((r) => {
366
+ place(r, x);
367
+ x += widthOf(r) + gap.x;
368
+ });
369
+ return frames.map((f, index) => {
370
+ const p = position.get(f.id) ?? { x: 0, y: 0 };
371
+ return { ...f, index, depth: depthOf.get(f.id) ?? 0, position: { x: Math.round(p.x), y: Math.round(p.y) } };
372
+ });
373
+ }
374
+
375
+ /**
376
+ * 寸法から位置を決める。contentList に階層(parentId)があればツリー、無ければ行で折り返す。
377
+ * 位置を状態に持たないので、高さが実測で変わっても流れ直すだけで済む
378
+ */
379
+ export function layoutFrames(
380
+ frames: PageFrame[],
381
+ editorMode: EditorMode,
382
+ ): { pages: PageFrameLayout[]; bounds: CanvasBounds; layout: CanvasLayoutKind } {
383
+ const gap = GAP[editorMode];
384
+ const ids = new Set(frames.map((f) => f.id));
385
+ const hasHierarchy = frames.some((f) => f.parentId && f.parentId !== f.id && ids.has(f.parentId));
386
+ const pages = hasHierarchy ? layoutTree(frames, gap) : layoutGrid(frames, gap, WRAP_AT[editorMode](frames.length));
387
+ return { pages, bounds: boundsOf(pages), layout: hasHierarchy ? 'tree' : 'grid' };
277
388
  }
278
389
 
279
390
  function fitView(
@@ -361,19 +472,36 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
361
472
  framesMapRef.current = framesMap;
362
473
 
363
474
  // contentList からフレームを同期(既存の実測・本文は保つ)
475
+ const currentContentIdRef = useRef(artboard.currentContentId);
476
+ currentContentIdRef.current = artboard.currentContentId;
364
477
  useEffect(() => {
365
478
  if (!enabled) return;
479
+ const ids = new Set(artboard.contentList.map((c) => c.id));
480
+ const depths = computeContentDepths(artboard.contentList);
366
481
  setFramesMap((prev) => {
367
482
  const next = new Map<string, PageFrame>();
368
483
  let changed = prev.size !== artboard.contentList.length;
369
484
  artboard.contentList.forEach((content) => {
370
485
  const existing = prev.get(content.id);
486
+ const parentId = content.parentId && ids.has(content.parentId) && content.parentId !== content.id ? content.parentId : null;
487
+ const depth = depths.get(content.id) ?? 0;
488
+ const revision = content.revision ?? 0;
489
+ const href = content.href;
371
490
  if (existing) {
372
491
  const title = content.title || existing.title;
373
- const html = existing.html ?? content.thumbnailHtml ?? null;
374
492
  const width = frameWidth;
375
- if (title !== existing.title || html !== existing.html || width !== existing.size.width) changed = true;
376
- next.set(content.id, { ...existing, title, html, size: { width, height: existing.size.height } });
493
+ // 利用側が版を進めた = 本文が外から変わった。編集中のページは触らず(編集中の本文が正)、
494
+ // 他のページは本文を捨てて読み直させる(紙面は次の本文が来るまで前の姿のまま)
495
+ const bumped = revision !== existing.revision;
496
+ const isLive = content.id === currentContentIdRef.current;
497
+ const html = bumped && !isLive ? null : existing.html ?? content.thumbnailHtml ?? null;
498
+ const stale = bumped && !isLive ? true : existing.stale;
499
+ if (
500
+ title !== existing.title || html !== existing.html || width !== existing.size.width ||
501
+ parentId !== existing.parentId || depth !== existing.depth || href !== existing.href ||
502
+ revision !== existing.revision || stale !== existing.stale
503
+ ) changed = true;
504
+ next.set(content.id, { ...existing, title, html, stale, parentId, depth, href, revision, size: { width, height: existing.size.height } });
377
505
  } else {
378
506
  changed = true;
379
507
  next.set(content.id, {
@@ -385,6 +513,11 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
385
513
  size: { width: frameWidth, height: defaultHeight },
386
514
  measured: editorMode === 'slide',
387
515
  isDirty: false,
516
+ parentId,
517
+ depth,
518
+ href,
519
+ revision,
520
+ stale: false,
388
521
  });
389
522
  }
390
523
  });
@@ -392,7 +525,7 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
392
525
  });
393
526
  }, [enabled, artboard.contentList, frameWidth, defaultHeight, editorMode]);
394
527
 
395
- const { pages, bounds } = useMemo(() => {
528
+ const { pages, bounds, layout } = useMemo(() => {
396
529
  const order = artboard.contentList.map((c) => c.id);
397
530
  const frames = order.map((id) => framesMap.get(id)).filter((f): f is PageFrame => !!f);
398
531
  return layoutFrames(frames, editorMode);
@@ -428,10 +561,26 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
428
561
  }, []);
429
562
 
430
563
  const setPageHtml = useCallback(
431
- (id: string, html: string) => updatePageFrame(id, { html, loading: false, error: null }),
564
+ (id: string, html: string) => updatePageFrame(id, { html, loading: false, error: null, stale: false }),
432
565
  [updatePageFrame],
433
566
  );
434
567
 
568
+ // 見るだけの紙面の本文を捨てて読み直させる(部品の反映など、外からファイルが変わったとき)
569
+ const invalidatePages = useCallback<MultiPageCanvasContextValue['invalidatePages']>((options) => {
570
+ if (!io().loadContent) return; // 読み直す手段が無い(thumbnailHtml は最初の姿のままなので読み直しにならない)
571
+ const except = new Set((options?.except ?? []).filter((id): id is string => !!id));
572
+ setFramesMap((prev) => {
573
+ let changed = false;
574
+ const next = new Map(prev);
575
+ prev.forEach((frame, id) => {
576
+ if (except.has(id) || frame.html == null) return;
577
+ next.set(id, { ...frame, html: null, loading: false, error: null, stale: true });
578
+ changed = true;
579
+ });
580
+ return changed ? next : prev;
581
+ });
582
+ }, []);
583
+
435
584
  // ---- ビュー状態(外部ストア。ズーム・パンで Context の値は変えない)
436
585
  const persisted = useMemo(() => readPersistedView(storageKey), [storageKey]);
437
586
  const [viewStore] = useState<CanvasViewStore>(() =>
@@ -669,12 +818,13 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
669
818
  async (id) => {
670
819
  const frame = framesMapRef.current.get(id);
671
820
  if (!frame || frame.html != null || loadingRef.current.has(id)) return;
821
+ const loader = io().loadContent;
822
+ // 最初は利用側が渡した本文(thumbnailHtml)で足りる。外から変わった(stale)あとは loadContent で読み直す
672
823
  const fromList = artboard.contentList.find((c) => c.id === id)?.thumbnailHtml;
673
- if (fromList != null) {
824
+ if (fromList != null && !(frame.stale && loader)) {
674
825
  setPageHtml(id, fromList);
675
826
  return;
676
827
  }
677
- const loader = io().loadContent;
678
828
  if (!loader) return;
679
829
  loadingRef.current.add(id);
680
830
  updatePageFrame(id, { loading: true, error: null });
@@ -786,9 +936,12 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
786
936
  editorMode,
787
937
  pages,
788
938
  bounds,
939
+ layout,
789
940
  getPage,
790
941
  updatePageFrame,
791
942
  setPageHtml,
943
+ invalidatePages,
944
+ documentAttributes: artboard.documentAttributes,
792
945
  setPageHeight,
793
946
  ensurePageHtml,
794
947
  previewStyles,
@@ -817,8 +970,8 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
817
970
  releasePreviewSlot,
818
971
  }),
819
972
  [
820
- enabled, editorMode, pages, bounds, getPage, updatePageFrame, setPageHtml, setPageHeight, ensurePageHtml,
821
- previewStyles, viewStore, setCanvasOffset, setCanvasZoom, setView, zoomAt, zoomTo, zoomIn, zoomOut, zoomToFit, zoomToPage,
973
+ enabled, editorMode, pages, bounds, layout, getPage, updatePageFrame, setPageHtml, invalidatePages, artboard.documentAttributes,
974
+ setPageHeight, ensurePageHtml, previewStyles, viewStore, setCanvasOffset, setCanvasZoom, setView, zoomAt, zoomTo, zoomIn, zoomOut, zoomToFit, zoomToPage,
822
975
  zoomToActual, revealPage, activatePage, activatingPageId, registerContainer, isInteracting, markInteracting,
823
976
  persisted, rulersVisible, toggleRulers, requestPreviewSlot, releasePreviewSlot,
824
977
  ],
@@ -1301,6 +1301,22 @@ function drawSelectionBox(
1301
1301
  selectionBox.className =
1302
1302
  mode === 'member' ? 'selection-box selection-member' : 'selection-box';
1303
1303
  selectionBox.setAttribute('data-for-element', elementId);
1304
+ // 部品(data-part)のインスタンスは枠を紫にして、名前と版を枠の上に出す(部品だと一目で分かる)。
1305
+ // 部品の中のスロットを選んでいるときも、どの部品の中かを出す
1306
+ const partRoot = element.hasAttribute('data-part') ? element : (element.parentElement?.closest('[data-part]') as HTMLElement | null) ?? null;
1307
+ if (partRoot) {
1308
+ const isRoot = partRoot === element;
1309
+ selectionBox.classList.add(isRoot ? 'selection-part' : 'selection-in-part');
1310
+ if (mode !== 'member') {
1311
+ const badge = iframeDoc.createElement('div');
1312
+ badge.className = 'part-badge';
1313
+ const slot = isRoot ? null : (element.closest('[data-slot]') as HTMLElement | null);
1314
+ const slotName = slot && partRoot.contains(slot) && slot !== partRoot ? slot.getAttribute('data-slot') : null;
1315
+ const version = partRoot.getAttribute('data-part-v');
1316
+ badge.textContent = `${isRoot ? '部品' : '部品の中'} ${partRoot.getAttribute('data-part') ?? ''}${version ? ` v${version}` : ''}${slotName ? ` › ${slotName}` : ''}`;
1317
+ selectionBox.appendChild(badge);
1318
+ }
1319
+ }
1304
1320
  selectionBox.style.cssText = `
1305
1321
  left: ${relativeLeft}px;
1306
1322
  top: ${relativeTop}px;
@@ -185,9 +185,14 @@ export function generatePreviewHtml(
185
185
  .join('\n ');
186
186
  const width = editorMode === 'webpage' ? (artboardWidth ?? WEBPAGE_WIDTH) : SLIDE_WIDTH;
187
187
  const heightRule = editorMode === 'webpage' ? '' : `height: ${SLIDE_HEIGHT}px; overflow: hidden;`;
188
- // sandbox(allow-same-origin のみ)では script が動かない。残すと 1 枚ごとに
189
- // 「Blocked script execution」がコンソールへ出るだけなので、本文からも head からも外す
190
- const stripScripts = (html: string) => html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
188
+ // 見るだけの紙面では script を動かさない(ブラウザ版 Tailwind の JIT やページの script は要らない)。
189
+ // <script> と、勝手に発火しうるインラインハンドラ(onload / onerror 等)を本文からも head からも外す。
190
+ // iframe に sandbox は付けない: sandbox で script を禁じると、ページへ script を差し込む
191
+ // ブラウザ拡張(React DevTools 等)が 1 枚ごとに「Blocked script execution」をコンソールへ出す(実測 26 枚 = 26 件)
192
+ const stripScripts = (html: string) =>
193
+ html
194
+ .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')
195
+ .replace(/\son(?:load|error|abort|click|dblclick|mouse\w*|pointer\w*|touch\w*|key\w*|focus\w*|blur|change|input|submit|reset|scroll|wheel|animation\w*|transition\w*|toggle|play\w*|pause|ended|canplay\w*|loaded\w*)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '');
191
196
  return `<!DOCTYPE html>
192
197
  <html lang="ja">
193
198
  <head>${stripScripts(buildCommonHead(content))}
@@ -234,6 +239,17 @@ export function generateEditableHtml(
234
239
  <head>${buildCommonHead(content)}
235
240
  <style>
236
241
  * { box-sizing: border-box; }
242
+ /* 既定の書体はカスケードレイヤーに入れて置く(レイヤーに入れた宣言は、レイヤー外の宣言と、
243
+ 後から宣言されたレイヤーの両方に負ける)。ページ側の CSS が html { font-family } を
244
+ 素で書いていても、Tailwind v4 のように @layer base に書いていても、そちらが勝つ。
245
+ 以前は html, body に直接書いていたため、構成ラフの @layer base の html { font-family: "Noto Sans JP" } が
246
+ 負けて、エディタだけシステム書体で表示されていた(実測。:where() でも @layer には勝ってしまう)。
247
+ スライド本体は #artboard 内のルートが font-sans を持つのでどちらでも影響しない */
248
+ @layer gg-editor-defaults {
249
+ html {
250
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Hiragino Sans', 'Noto Sans JP', Meiryo, sans-serif;
251
+ }
252
+ }
237
253
  html, body {
238
254
  margin: 0;
239
255
  padding: 0;
@@ -241,9 +257,6 @@ export function generateEditableHtml(
241
257
  height: 100%;
242
258
  overflow: hidden;
243
259
  background-color: ${CANVAS_BG_COLOR};
244
- /* iframe内のエディタUI(パンくず・ラベル等)用。
245
- スライド本体は #artboard 内のルートが font-sans(游ゴシック)を持つので影響しない */
246
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Hiragino Sans', 'Noto Sans JP', Meiryo, sans-serif;
247
260
  }
248
261
 
249
262
  /* スクロール可能なキャンバスコンテナ */
package/src/index.ts CHANGED
@@ -22,11 +22,16 @@ export type { FrontendVisualEditorProps } from "./editor/FrontendVisualEditor";
22
22
 
23
23
  export { EditorProvider, useEditorContext } from "./editor/EditorContext";
24
24
  export type { ContentListItem, EditorMode } from "./editor/EditorContext";
25
+ export type { DocumentAttributes } from "./editor/contexts/EditorArtboardContext";
26
+ export { computeContentDepths } from "./editor/contexts/EditorArtboardContext";
25
27
 
26
28
  export { WEBPAGE_WIDTH, WEBPAGE_MIN_HEIGHT, SLIDE_WIDTH, SLIDE_HEIGHT, BREAKPOINT_PRESETS } from "./editor/constants";
27
29
 
28
30
  export { registerAutoSaveFlush, flushAutoSave } from "./editor/autosave";
29
31
 
32
+ /** パッケージの版。利用側が dev サーバーの束ねたコードと node_modules の食い違い(再起動漏れ)を検出するのに使う */
33
+ export { EDITOR_VERSION } from "./version";
34
+
30
35
  /**
31
36
  * コメントの一覧(カンバン)。エディタとは別の画面に単独で置ける。
32
37
  *
@@ -258,9 +258,46 @@
258
258
  .gg-editor-ui .ed-resize-handle:hover, .gg-editor-ui .ed-resize-handle:focus-visible { background-color: var(--ed-accent); }
259
259
  .gg-editor-ui .ed-layer-selected { box-shadow: inset 3px 0 var(--ed-accent); }
260
260
  .gg-editor-ui .ed-layer-selected :is(.text-white, .text-blue-100, .text-blue-200) { color: var(--ed-accent); }
261
- .gg-editor-ui [data-property-panel] input:not([type="color"]), .gg-editor-ui [data-property-panel] select { min-height: 28px; }
262
- .gg-editor-ui [data-property-panel] button[data-state] { min-height: 32px; }
261
+ /* ── 右のプロパティパネル(Figma UI3 の寸法に寄せる) ──────────────
262
+ 節は 1px の罫線で区切り、見出しは 13px/600・高さ 40px、中身は左右 16px。
263
+ 入力は高さ 32px・角丸 6px・面の色(枠線は無し。ホバーで枠、フォーカスでアクセント)。
264
+ ラベルは 11px の補助色。数値の小さな入力(十字のパディング等)は 28px */
263
265
  .gg-editor-ui [data-property-panel] .ed-panel-heading { position: sticky; top: 0; z-index: 1; }
266
+ .gg-editor-ui [data-property-panel] .p-3 { padding: 0; }
267
+ .gg-editor-ui [data-property-panel] .p-3 > * { padding-left: 16px; padding-right: 16px; }
268
+ .gg-editor-ui [data-property-panel] .p-3 > .mb-3 { min-height: 44px; margin-bottom: 0; }
269
+ .gg-editor-ui [data-property-panel] .p-3 > [data-state] { border-top: 1px solid var(--ed-line); }
270
+ .gg-editor-ui [data-property-panel] .p-3 > .ed-part-card { margin: 0 16px 12px; padding: 10px 12px; }
271
+ .gg-editor-ui [data-property-panel] [data-state] > button[data-state] {
272
+ min-height: 40px; padding: 0; border-top: 0; font-size: 13px; font-weight: 600; color: var(--ed-text); border-radius: 0;
273
+ }
274
+ .gg-editor-ui [data-property-panel] [data-state] > button[data-state]:hover { background: transparent; color: var(--ed-text); }
275
+ .gg-editor-ui [data-property-panel] [data-state] > button[data-state] > span:first-child > svg { width: 14px; height: 14px; color: var(--ed-muted); }
276
+ .gg-editor-ui [data-property-panel] [data-state] > button[data-state] > svg:last-child { width: 14px; height: 14px; color: var(--ed-muted); }
277
+ .gg-editor-ui [data-property-panel] [data-state] > div[data-state="open"] { padding-bottom: 16px; }
278
+ .gg-editor-ui [data-property-panel] label, .gg-editor-ui [data-property-panel] .ed-prop-label { font-size: 11px; line-height: 1.4; color: var(--ed-muted); font-weight: 500; }
279
+ .gg-editor-ui [data-property-panel] :is(input:not([type="color"]):not([type="checkbox"]):not([type="range"]), select, [role="combobox"]):not(.group *) {
280
+ min-height: 32px; height: 32px; border-radius: 6px; background: var(--ed-surface); border: 1px solid transparent !important;
281
+ color: var(--ed-text); font-size: 12px; padding-left: 8px; padding-right: 8px; box-shadow: none;
282
+ }
283
+ .gg-editor-ui [data-property-panel] :is(input:not([type="color"]):not([type="checkbox"]):not([type="range"]), select, [role="combobox"]):not(.group *):hover { border-color: var(--ed-line-strong) !important; }
284
+ .gg-editor-ui [data-property-panel] :is(input:not([type="color"]):not([type="checkbox"]):not([type="range"]), select, [role="combobox"]):not(.group *):focus-visible,
285
+ .gg-editor-ui [data-property-panel] :is(input:not([type="color"]):not([type="checkbox"]):not([type="range"]), select, [role="combobox"]):not(.group *):focus-within { border-color: var(--ed-accent) !important; outline: none; box-shadow: none; }
286
+ /* 数値のスクラブ入力(CompactNumberInput。器 .group が枠) */
287
+ .gg-editor-ui [data-property-panel] .group.h-5 { height: 28px; min-width: 40px; border-radius: 6px; background: var(--ed-surface); border-color: transparent; }
288
+ .gg-editor-ui [data-property-panel] .group.h-5:hover { border-color: var(--ed-line-strong); }
289
+ .gg-editor-ui [data-property-panel] .group.h-5:focus-within { border-color: var(--ed-accent); }
290
+ .gg-editor-ui [data-property-panel] .group.h-5 input { font-size: 12px; color: var(--ed-text); }
291
+ .gg-editor-ui [data-property-panel] .group.h-5 input:focus { box-shadow: none; }
292
+ /* 配置・書式のトグル(h-7 w-7)は 32px の角丸 6px。押下は面の色で示す */
293
+ .gg-editor-ui [data-property-panel] button.h-7.w-7, .gg-editor-ui [data-property-panel] button.h-7 { height: 32px; min-width: 32px; border-radius: 6px; }
294
+ .gg-editor-ui [data-property-panel] button.h-7.w-7 svg { width: 14px; height: 14px; }
295
+ .gg-editor-ui [data-property-panel] button[data-state]:not([role="checkbox"]):not([role="switch"]) { min-height: 32px; }
296
+ .gg-editor-ui [data-property-panel] button[role="checkbox"] { width: 16px; height: 16px; min-height: 16px; border-radius: 4px; border: 1px solid var(--ed-line-strong); background: var(--ed-surface); }
297
+ .gg-editor-ui [data-property-panel] button[role="checkbox"][data-state="checked"] { background: var(--ed-accent-strong); border-color: var(--ed-accent-strong); color: #fff; }
298
+ .gg-editor-ui [data-property-panel] input[type="color"] { width: 28px; height: 28px; border-radius: 6px; border: 1px solid var(--ed-line-strong); padding: 0; }
299
+ .gg-editor-ui [data-property-panel] .grid.grid-cols-2 { gap: 8px; }
300
+ .gg-editor-ui [data-property-panel] .space-y-2 > * + *, .gg-editor-ui [data-property-panel] .space-y-3 > * + * { margin-top: 10px; }
264
301
  /* ポータル自身も配色スコープ。利用側にToasterがある場合もその設定を奪わない。 */
265
302
  .gg-editor-ui[data-editor-surface="popup"], .gg-editor-ui[data-editor-surface="dialog"], .gg-editor-ui[data-editor-surface="tooltip"] {
266
303
  color: var(--ed-text); background-color: var(--ed-panel-raised); border-color: var(--ed-line); border-radius: 12px; box-shadow: var(--ed-shadow); z-index: 10000;
@@ -290,11 +327,28 @@
290
327
  /* ── マルチフレームのキャンバス(Figma 風) ──────────────
291
328
  フレーム名とリングは画面座標に置く(倍率に関係なく同じ太さ・同じ文字サイズ)。
292
329
  見るだけの紙面の骨組み(スケルトン)だけは転写層の中にあるので、フレームの寸法に対する % で描く */
330
+ .gg-editor-ui .ed-frame-label-row { position: absolute; left: 0; height: 20px; display: inline-flex; align-items: center; gap: 2px; max-width: 100%; }
293
331
  .gg-editor-ui .ed-frame-label {
294
- position: absolute; left: 0; height: 20px; display: inline-flex; align-items: center; gap: 6px;
332
+ height: 20px; min-width: 0; display: inline-flex; align-items: center; gap: 6px;
295
333
  padding: 0 6px 0 4px; border-radius: 4px; font-size: 11px; line-height: 1; white-space: nowrap;
296
334
  color: var(--ed-muted); pointer-events: auto; cursor: pointer; overflow: hidden; text-overflow: ellipsis;
297
335
  }
336
+ /* 別タブで開く(編集しない表示)。名前の右。ホバー・編集中のときだけ見せる */
337
+ .gg-editor-ui .ed-frame-label-link {
338
+ display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; width: 18px; height: 18px;
339
+ border-radius: 4px; color: var(--ed-muted); pointer-events: auto; opacity: 0; transition: opacity .12s;
340
+ }
341
+ .gg-editor-ui .ed-frame-label-link svg { width: 12px; height: 12px; }
342
+ .gg-editor-ui .ed-frame-label-row:hover .ed-frame-label-link, .gg-editor-ui .ed-frame-label-active + .ed-frame-label-link, .gg-editor-ui .ed-frame-label-link:focus-visible { opacity: 1; }
343
+ .gg-editor-ui .ed-frame-label-link:hover { color: var(--ed-text); background: var(--ed-panel); box-shadow: 0 0 0 1px var(--ed-line); }
344
+ /* 階層の線(ツリー配置)。転写層の中にあるが、線の太さは non-scaling-stroke で画面上 1.5px */
345
+ .gg-editor-ui .ed-frame-connectors { position: absolute; left: 0; top: 0; pointer-events: none; overflow: visible; }
346
+ .gg-editor-ui .ed-frame-connectors path { stroke: var(--ed-line-strong); stroke-width: 1.5px; stroke-linejoin: round; }
347
+ /* 部品(data-part)のカード。紙面の紫の選択枠と同じ色 */
348
+ .gg-editor-ui .ed-part-card { border: 1px solid color-mix(in srgb, #7b61ff 45%, var(--ed-line)); background: color-mix(in srgb, #7b61ff 8%, var(--ed-panel)); color: var(--ed-text); }
349
+ .gg-editor-ui .ed-part-accent { color: #7b61ff; }
350
+ .gg-editor-ui[data-editor-theme="dark"] .ed-part-accent { color: #a99cff; }
351
+ .gg-editor-ui .ed-part-note { color: var(--ed-muted); }
298
352
  .gg-editor-ui .ed-frame-label:hover { color: var(--ed-text); background: var(--ed-panel); box-shadow: 0 0 0 1px var(--ed-line); }
299
353
  .gg-editor-ui .ed-frame-label-active, .gg-editor-ui .ed-frame-label-active:hover { color: var(--ed-accent); font-weight: 600; }
300
354
  .gg-editor-ui .ed-frame-label-index { font-variant-numeric: tabular-nums; opacity: 0.7; }
package/src/version.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * パッケージの版(package.json の version と同じ値。scripts/check-version.mjs が一致を検査する)。
3
+ *
4
+ * 利用側が「いま動いているエディタの版」を知るために使う。dev サーバー(Vite)は起動時に
5
+ * node_modules を束ねるので、パッケージを更新しても再起動するまで古いコードが動く。
6
+ * 利用側はこの値と node_modules の package.json を見比べて、食い違っていれば
7
+ * 「再起動してください」と出せる(構成ラフのテンプレートが行っている)
8
+ */
9
+ export const EDITOR_VERSION = '0.3.1';