Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

- Synchronized workspace package versions from `0.3.8` to `0.3.9` across root, core, wrappers, sequence, server, docs, and demos.
- Updated inter-package dependency references to `0.3.9` for cross-package compatibility (`core`->`sequence`, `react`/`vue`->`core`, `docs`/demos->core or wrappers).
- Expanded `README.md` with repository badges (npm, license, CI, docs deploy, docs URL, Node requirement) and added `README.ja.md` for Japanese onboarding.
- Size-first refactor:
- split renderer internals into focused modules (`rendererTypes`, `rendererShared`, `htmlRenderer`, `canvasRenderer`) and kept `renderers` as a re-export surface.
- extracted shared formatter and selection helper modules (`valueFormatter`, `selectionClipboard`, `selectionCoercion`, `selectionEditorFactory`, `selectionInitialValue`) to reduce duplication.
Expand Down
36 changes: 36 additions & 0 deletions README.ja.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Extable

固定カラムスキーマを持つ、ExcelライクなHTMLテーブルコンポーネントです。Google Sheetsのテーブルモードに近い操作感で、マルチユーザー編集にも対応しています。

[English](./README.md) | [日本語](./README.ja.md)

[![npm version](https://img.shields.io/npm/v/%40extable%2Fcore)](https://www.npmjs.com/package/@extable/core)
[![License](https://img.shields.io/github/license/shibukawa/extable)](./LICENSE)
[![CI Tests](https://github.com/shibukawa/extable/actions/workflows/test.yml/badge.svg)](https://github.com/shibukawa/extable/actions/workflows/test.yml)
[![Deploy Docs](https://github.com/shibukawa/extable/actions/workflows/pages.yml/badge.svg)](https://github.com/shibukawa/extable/actions/workflows/pages.yml)
[![Docs](https://img.shields.io/badge/docs-GitHub%20Pages-1f6feb)](https://shibukawa.github.io/extable/)
[![Node](https://img.shields.io/badge/node-%3E%3D22-339933?logo=node.js&logoColor=white)](https://nodejs.org/)

## ドキュメント

ドキュメントサイトは `docs/pages` を元に VitePress で構築し、GitHub Pages に公開しています。

- Docs URL: `https://shibukawa.github.io/extable/`
- ローカル起動: `npm run docs:dev`

## デモ

- Vanilla デモ(ポート `5173`): `npm run dev:demo`
- React デモ(ポート `5174`): `npm run dev:demo-react`
- Vue デモ(ポート `5175`): `npm run dev:demo-vue`

## 開発

- Node: `>=22`
- インストール: `npm install --cache .npm-cache`
- ビルド: `npm run build`
- テスト: `npm run test`

## ライセンス

Apache-2.0。`LICENSE` を参照してください。
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
# Extable

Excel-like HTML table component with a fixed column schema (similar to Google Sheets’ table mode) and built-in multi-user editing.
Excel-like HTML table component with a fixed column schema (similar to Google Sheets' table mode) and built-in multi-user editing.

[English](./README.md) | [日本語](./README.ja.md)

[![npm version](https://img.shields.io/npm/v/%40extable%2Fcore)](https://www.npmjs.com/package/@extable/core)
[![License](https://img.shields.io/github/license/shibukawa/extable)](./LICENSE)
[![CI Tests](https://github.com/shibukawa/extable/actions/workflows/test.yml/badge.svg)](https://github.com/shibukawa/extable/actions/workflows/test.yml)
[![Deploy Docs](https://github.com/shibukawa/extable/actions/workflows/pages.yml/badge.svg)](https://github.com/shibukawa/extable/actions/workflows/pages.yml)
[![Docs](https://img.shields.io/badge/docs-GitHub%20Pages-1f6feb)](https://shibukawa.github.io/extable/)
[![Node](https://img.shields.io/badge/node-%3E%3D22-339933?logo=node.js&logoColor=white)](https://nodejs.org/)

![screenshot](https://github.com/shibukawa/extable/blob/main/packages/docs/public/assets/screenshot.webp?raw=true)

## Documentation

The docs site is built with VitePress from `docs/pages` and deployed to GitHub Pages.

- Repo-pages URL (typical): `https://shibukawa.github.io/extable/`
- Docs URL: `https://shibukawa.github.io/extable/`
- Local dev: `npm run docs:dev`

## Demos
Expand All @@ -27,4 +36,3 @@ The docs site is built with VitePress from `docs/pages` and deployed to GitHub P
## License

Apache-2.0. See `LICENSE`.

44 changes: 30 additions & 14 deletions packages/core/src/canvasRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,13 @@ export class CanvasRenderer implements Renderer {
const textAreaY = yCursor + CELL_PADDING_TOP_PX;
const textAreaW = Math.max(0, w - CELL_PADDING_X_PX * 2);
const textAreaH = Math.max(0, rowH - (CELL_PADDING_TOP_PX + CELL_PADDING_BOTTOM_PX));
const buttonTextPadX = 10;
const textDrawX =
renderAction && c.type === "button" ? textAreaX + buttonTextPadX : textAreaX;
const textDrawW =
renderAction && c.type === "button"
? Math.max(0, textAreaW - buttonTextPadX * 2)
: textAreaW;
const decorations = {
underline: Boolean(mergedStyle.underline) || (renderAction && c.type === "link"),
strike: Boolean(mergedStyle.strike),
Expand All @@ -412,9 +419,9 @@ export class CanvasRenderer implements Renderer {
? this.measureTextBounds(
ctx,
text,
textAreaX,
textDrawX,
textAreaY,
textAreaW,
textDrawW,
textAreaH,
wrap,
align,
Expand Down Expand Up @@ -469,9 +476,9 @@ export class CanvasRenderer implements Renderer {
this.drawCellText(
ctx,
text,
textAreaX,
textDrawX,
textAreaY,
textAreaW,
textDrawW,
textAreaH,
wrap,
align,
Expand Down Expand Up @@ -916,12 +923,16 @@ export class CanvasRenderer implements Renderer {
0,
hit.rect.height - (CELL_PADDING_TOP_PX + CELL_PADDING_BOTTOM_PX),
);
const buttonTextPadX = 10;
const textDrawX = col.type === "button" ? textAreaX + buttonTextPadX : textAreaX;
const textDrawW =
col.type === "button" ? Math.max(0, textAreaW - buttonTextPadX * 2) : textAreaW;
const bounds = this.measureTextBounds(
ctx,
actionValue.label,
textAreaX,
textDrawX,
textAreaY,
textAreaW,
textDrawW,
textAreaH,
wrap,
align,
Expand Down Expand Up @@ -1350,6 +1361,8 @@ export class CanvasRenderer implements Renderer {
): DOMRect | null {
const lines = this.getTextLinesForBounds(ctx, text, width, wrap, height);
if (!lines.length) return null;
const totalTextHeight = lines.length * this.lineHeight;
const baseY = y + Math.max(0, Math.floor((height - totalTextHeight) / 2));
let minX = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
Expand All @@ -1360,7 +1373,7 @@ export class CanvasRenderer implements Renderer {
let startX = x;
if (align === "right") startX = x + width - lineWidth;
else if (align === "center") startX = x + (width - lineWidth) / 2;
const lineTop = y + this.lineHeight * idx;
const lineTop = baseY + this.lineHeight * idx;
const lineBottom = lineTop + this.lineHeight;
minX = Math.min(minX, startX);
maxX = Math.max(maxX, startX + lineWidth);
Expand Down Expand Up @@ -1390,6 +1403,8 @@ export class CanvasRenderer implements Renderer {
ctx.rect(x - 4, y - 4, width + 8, height + 8);
ctx.clip();
const fontBackup = ctx.font;
const baselineBackup = ctx.textBaseline;
ctx.textBaseline = "top";
if (isBoolean) {
if (isUniqueBoolean) {
ctx.font = `${UNIQUE_BOOL_FONT_SIZE_PX}px ${CANVAS_FONT_FAMILY}`;
Expand All @@ -1403,26 +1418,26 @@ export class CanvasRenderer implements Renderer {
const totalTextHeight = lines.length * this.lineHeight;
const baseY = y + Math.max(0, Math.floor((height - totalTextHeight) / 2));
const renderLine = (ln: string, lineIdx: number) => {
const baseline = baseY + this.lineHeight * lineIdx;
const lineTop = baseY + this.lineHeight * lineIdx;
let startX = x;
let endX = x;
if (align === "right") {
ctx.textAlign = "right";
endX = x + width;
startX = endX - ctx.measureText(ln).width;
ctx.fillText(ln, endX, baseline);
ctx.fillText(ln, endX, lineTop);
} else if (align === "center") {
ctx.textAlign = "center";
const center = x + width / 2;
const w = ctx.measureText(ln).width;
startX = center - w / 2;
endX = center + w / 2;
ctx.fillText(ln, center, baseline);
ctx.fillText(ln, center, lineTop);
} else {
ctx.textAlign = "left";
startX = x;
endX = x + ctx.measureText(ln).width;
ctx.fillText(ln, x, baseline);
ctx.fillText(ln, x, lineTop);
}
if (decorations?.underline || decorations?.strike) {
const strokeBackup = ctx.strokeStyle;
Expand All @@ -1431,12 +1446,12 @@ export class CanvasRenderer implements Renderer {
ctx.lineWidth = 1;
ctx.beginPath();
if (decorations.underline) {
const yUnderline = baseline + 2;
const yUnderline = lineTop + this.lineHeight - 2;
ctx.moveTo(startX, yUnderline);
ctx.lineTo(endX, yUnderline);
}
if (decorations.strike) {
const yStrike = baseline - Math.floor(this.lineHeight / 2) + 2;
const yStrike = lineTop + Math.floor(this.lineHeight / 2);
ctx.moveTo(startX, yStrike);
ctx.lineTo(endX, yStrike);
}
Expand All @@ -1446,9 +1461,10 @@ export class CanvasRenderer implements Renderer {
}
};
for (let idx = 0; idx < lines.length; idx += 1) {
renderLine(lines[idx] ?? "", idx + 1);
renderLine(lines[idx] ?? "", idx);
}
ctx.textAlign = "left";
ctx.textBaseline = baselineBackup;
ctx.font = fontBackup;
ctx.restore();
}
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -305,19 +305,25 @@
& .extable-action-link {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
max-width: 100%;
font: inherit;
line-height: 1.2;
color: inherit;
background: transparent;
border: none;
padding: 0;
margin: 0;
box-sizing: border-box;
vertical-align: middle;
cursor: pointer;
text-decoration: none;
}

& .extable-action-button {
padding: 4px 10px;
min-height: 24px;
border-radius: 8px;
border: 1px solid #94a3b8;
background: linear-gradient(180deg, #f8fafc 0%, #e2e8f0 100%);
Expand Down
30 changes: 27 additions & 3 deletions packages/demo-react/src/data/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export interface DemoRow {
description: string;
longNote: string;
longNoWrap: string;
actionButton: { label: string; command: string; commandfor: string };
actionLink: { label: string; href: string; target?: string };
}

export const demoRows: DemoRow[] = [
Expand Down Expand Up @@ -39,7 +41,17 @@ for (let i = 1; i <= 100; i += 1) {
'This is an extremely long single-line note intended to verify ellipsis when wrapping is disabled. ' +
'It should exceed the column width significantly without containing newline characters.',
longNoWrap:
'A very very long text that should stay single-line and demonstrate ellipsis behavior when wrap is off for this column.'
'A very very long text that should stay single-line and demonstrate ellipsis behavior when wrap is off for this column.',
actionButton: {
label: i % 2 === 0 ? 'Approve' : 'Review',
command: i % 2 === 0 ? 'approve' : 'review',
commandfor: String(i),
},
actionLink: {
label: 'Profile',
href: `https://example.com/users/${i}`,
target: '_blank',
}
});
}

Expand All @@ -63,7 +75,17 @@ export function makePerformanceDemoRows(count = 10000): DemoRow[] {
score: Math.round((50 + (i % 50)) * 100) / 100,
description: shortDescription,
longNote,
longNoWrap
longNoWrap,
actionButton: {
label: i % 2 === 0 ? 'Approve' : 'Review',
command: i % 2 === 0 ? 'approve' : 'review',
commandfor: String(i),
},
actionLink: {
label: 'Profile',
href: `https://example.com/users/${i}`,
target: '_blank',
}
});
}
return rows;
Expand All @@ -82,7 +104,9 @@ export const demoSchema = {
{ key: 'score', header: 'Score', type: 'number', format: { precision: 6, scale: 2 }, style: { align: 'right' }, width: 100 },
{ key: 'description', header: 'Description', type: 'string', width: 260, wrapText: true },
{ key: 'longNote', header: 'Long Note (wrap)', type: 'string', width: 260, wrapText: true },
{ key: 'longNoWrap', header: 'Long Note (ellipsis)', type: 'string', width: 240 }
{ key: 'longNoWrap', header: 'Long Note (ellipsis)', type: 'string', width: 240 },
{ key: 'actionButton', header: 'Action', type: 'button', width: 120 },
{ key: 'actionLink', header: 'Link', type: 'link', width: 140 }
]
};

Expand Down
7 changes: 1 addition & 6 deletions packages/demo-react/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export function App() {
const tableRef = useRef<ExtableHandle>(null);
const [tableState, setTableState] = useState<TableState | null>(null);
const [history, setHistory] = useState<UndoRedoHistory>({ undo: [], redo: [] });
const [asyncData, setAsyncData] = useState<NullableData<DemoRow[]>>(null);
const [asyncData, setAsyncData] = useState<NullableData<DemoRow>>(null);
const loadGenerationRef = useRef(0);
const loadTimerRef = useRef<number | null>(null);
const renderModeFirstRef = useRef(true);
Expand Down Expand Up @@ -239,11 +239,6 @@ export function App() {
"- conditionalStyle: (row) => StyleDelta | null | Error (warning) | throw (error)",
"- Warning/Error is shown as a corner marker with hover message.",
];
if (dataMode === "unique-bool") {
headerLines.push(
"- commit mode: unique-boolean changes show red (current) and gray (previous) dots.",
);
}
headerLines.push("", "Sources:", "");
return headerLines.join("\n") + dataNoteForSchema(cfg.schema);
})();
Expand Down
30 changes: 27 additions & 3 deletions packages/demo-vue/src/data/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export interface DemoRow {
description: string;
longNote: string;
longNoWrap: string;
actionButton: { label: string; command: string; commandfor: string };
actionLink: { label: string; href: string; target?: string };
}

export const demoRows: DemoRow[] = [
Expand Down Expand Up @@ -39,7 +41,17 @@ for (let i = 1; i <= 100; i += 1) {
'This is an extremely long single-line note intended to verify ellipsis when wrapping is disabled. ' +
'It should exceed the column width significantly without containing newline characters.',
longNoWrap:
'A very very long text that should stay single-line and demonstrate ellipsis behavior when wrap is off for this column.'
'A very very long text that should stay single-line and demonstrate ellipsis behavior when wrap is off for this column.',
actionButton: {
label: i % 2 === 0 ? 'Approve' : 'Review',
command: i % 2 === 0 ? 'approve' : 'review',
commandfor: String(i),
},
actionLink: {
label: 'Profile',
href: `https://example.com/users/${i}`,
target: '_blank',
}
});
}

Expand All @@ -63,7 +75,17 @@ export function makePerformanceDemoRows(count = 10000): DemoRow[] {
score: Math.round((50 + (i % 50)) * 100) / 100,
description: shortDescription,
longNote,
longNoWrap
longNoWrap,
actionButton: {
label: i % 2 === 0 ? 'Approve' : 'Review',
command: i % 2 === 0 ? 'approve' : 'review',
commandfor: String(i),
},
actionLink: {
label: 'Profile',
href: `https://example.com/users/${i}`,
target: '_blank',
}
});
}
return rows;
Expand All @@ -82,7 +104,9 @@ export const demoSchema = {
{ key: 'score', header: 'Score', type: 'number', format: { precision: 6, scale: 2 }, style: { align: 'right' }, width: 100 },
{ key: 'description', header: 'Description', type: 'string', width: 260, wrapText: true },
{ key: 'longNote', header: 'Long Note (wrap)', type: 'string', width: 260, wrapText: true },
{ key: 'longNoWrap', header: 'Long Note (ellipsis)', type: 'string', width: 240 }
{ key: 'longNoWrap', header: 'Long Note (ellipsis)', type: 'string', width: 240 },
{ key: 'actionButton', header: 'Action', type: 'button', width: 120 },
{ key: 'actionLink', header: 'Link', type: 'link', width: 140 }
]
};

Expand Down
5 changes: 5 additions & 0 deletions packages/demo-vue/src/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<Record<string, never>, Record<string, never>, unknown>;
export default component;
}
Loading