mirror of
https://github.com/CorentinTh/it-tools.git
synced 2026-08-27 04:05:27 +00:00
feat(new tool): implement table to markdown generator
This commit is contained in:
parent
953c0a6749
commit
ed2d047ab0
8 changed files with 1682 additions and 161 deletions
178
docs/superpowers/specs/2026-07-08-table-to-markdown-design.md
Normal file
178
docs/superpowers/specs/2026-07-08-table-to-markdown-design.md
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
# 2026-07-08 Table to Markdown Generator Design
|
||||
|
||||
## Status
|
||||
Proposed (Pending User Review)
|
||||
|
||||
---
|
||||
|
||||
## 1. Context & Goals
|
||||
Markdown tables (GitHub Flavored Markdown) are extremely useful but tedious to write manually. There is a need for a modern, rich-text table editor within `my-it-tools` that allows developers to:
|
||||
1. Create, edit, and format tables visually.
|
||||
2. Intercept copy-paste events from spreadsheets (like Excel, Google Sheets) or HTML pages and auto-populate the table.
|
||||
3. Edit cell contents with standard text formatting (Bold, Italic) visually.
|
||||
4. Align columns (Left, Center, Right) visually and output the correct GFM alignment indicators.
|
||||
5. Export clean GFM Markdown with options for compact mode, beautify/padded mode, and proper representation of cell line breaks (using HTML `<br>`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Requirements
|
||||
|
||||
### Core Features
|
||||
1. **Interactive Table Editor:**
|
||||
- Strict table header (the first row).
|
||||
- Spreadsheet-like grid with cells editable using HTML `contenteditable`.
|
||||
- Selection/Focus tracking for active column/row index.
|
||||
- **Keyboard Navigation & Editing:**
|
||||
- `Tab`: Move focus to the next cell. If on the last cell of the table, auto-insert a new row and focus its first cell.
|
||||
- `Shift + Tab`: Move focus to the previous cell.
|
||||
- `Enter`: Move focus to the cell directly below. If on the last row, auto-create a new row below and focus the corresponding cell.
|
||||
- `Shift + Enter`: Insert a line break (`<br>`) inside the current cell.
|
||||
- **Right-click Actions:** Right-clicking on column headers or row header numbers opens a context menu to insert/delete columns/rows and adjust alignment.
|
||||
2. **Tabular Copy-Paste Interception:**
|
||||
- Copying tabular data from Excel, Google Sheets, Word, or web pages (which generate `text/html` `<table>` blocks) will populate the grid automatically.
|
||||
- Text formats like TSV/CSV or GFM Markdown tables pasted into the editor will be parsed.
|
||||
3. **Rich Text Formatting:**
|
||||
- Inline formatting (Bold, Italic) visually rendered inside cells, mapped to GFM syntax (`**` and `*`) upon Markdown generation.
|
||||
- Keyboard shortcuts (`Ctrl+B` for bold, `Ctrl+I` for italic) and toolbar buttons.
|
||||
4. **Column & Row Actions:**
|
||||
- Insert rows (above/below), insert columns (left/right).
|
||||
- Delete selected rows or columns.
|
||||
- Transpose the table (swap rows and columns).
|
||||
- Column alignment: left, center, right.
|
||||
5. **Output Options & Live Preview:**
|
||||
- **Compact Mode:** Omit padding spaces around cell contents (e.g., `|Cell 1|Cell 2|`) for a minified, space-saving output format.
|
||||
- **Beautify Mode (Padded):** Pad column cells with trailing spaces so they align vertically in the raw Markdown text editor (e.g., `| Header 1 | Long Cell 2 |`).
|
||||
- **Line Break Representation:** Since Markdown tables do not support actual multi-line formatting inside a cell, any cell line breaks (e.g. from Shift+Enter) are translated into `<br>` tags to preserve formatting.
|
||||
- **Markdown Code Block:** Live Markdown result with a one-click copy button and download option.
|
||||
- **Rendered Preview:** Live HTML rendering of the generated Markdown to verify the result visually.
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture & Data Model
|
||||
|
||||
We will build the module in a dedicated folder: `src/tools/table-to-markdown/`.
|
||||
|
||||
```
|
||||
src/tools/table-to-markdown/
|
||||
├── index.ts # Tool definition & registration
|
||||
├── table-to-markdown.vue # Main layout wrapper
|
||||
├── table-editor.vue # Component for editable table grid
|
||||
└── table-state.ts # Core model & utility methods for table state
|
||||
```
|
||||
|
||||
### Data Representation (`table-state.ts`)
|
||||
|
||||
```typescript
|
||||
export interface Cell {
|
||||
html: string; // HTML string containing formatting tags (<b>, <i>, <strong>, <em>, code)
|
||||
}
|
||||
|
||||
export type Alignment = 'left' | 'center' | 'right' | null;
|
||||
|
||||
export interface TableSnapshot {
|
||||
headers: Cell[];
|
||||
rows: Cell[][];
|
||||
alignments: Alignment[];
|
||||
}
|
||||
|
||||
export class TableState {
|
||||
public headers: Cell[] = [];
|
||||
public rows: Cell[][] = [];
|
||||
public alignments: Alignment[] = [];
|
||||
|
||||
// Undo/Redo history stacks
|
||||
private undoStack: TableSnapshot[] = [];
|
||||
private redoStack: TableSnapshot[] = [];
|
||||
|
||||
constructor(initialRows = 3, initialCols = 3) {
|
||||
this.reset(initialRows, initialCols);
|
||||
}
|
||||
|
||||
public reset(numRows: number, numCols: number) {
|
||||
this.headers = Array.from({ length: numCols }, () => ({ html: '' }));
|
||||
this.rows = Array.from({ length: numRows }, () =>
|
||||
Array.from({ length: numCols }, () => ({ html: '' }))
|
||||
);
|
||||
this.alignments = Array.from({ length: numCols }, () => null);
|
||||
this.clearHistory();
|
||||
}
|
||||
|
||||
// State operations...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Key Mechanics & Algorithms
|
||||
|
||||
### 1. Clipboard Paste Parser
|
||||
When pasting data into the editor:
|
||||
- **HTML Table (`text/html`):** Parse via DOMParser. Extract the first `<table>` element. Map headers (`<th>` or first row `<td>`s) and rows (`<td>`s). Cell HTML is sanitized using DOMPurify to preserve only formatting tags (`<b>`, `<strong>`, `<i>`, `<em>`, `<code>`, `<br>`).
|
||||
- **CSV / TSV (`text/plain`):** Check if content contains tabs or commas. Split rows by `\n` and cells by `\t` (Excel plain text copy) or `,`.
|
||||
- **GFM Table (`text/plain`):** Check if content matches markdown table syntax. If yes, parse cell Markdown contents and convert standard GFM to HTML.
|
||||
|
||||
### 2. GFM Cell Content Translation
|
||||
For each cell's HTML content, we perform a lightweight translation:
|
||||
- Replace `<strong>` / `<b>` elements with `**` wrapping the inner text.
|
||||
- Replace `em` / `i` elements with `*` wrapping the inner text.
|
||||
- Replace `code` elements with `` ` `` wrapping the inner text.
|
||||
- Convert visual line breaks (like `<br>`, block elements, or `\n` characters) to HTML `<br>` tags.
|
||||
- Escape any raw pipe characters (`|` -> `\|`) to prevent breaking the Markdown table structure.
|
||||
- Strip any other unexpected HTML elements while keeping their text.
|
||||
|
||||
### 3. Markdown Generator
|
||||
```typescript
|
||||
public toMarkdown(options: { compact: boolean }): string {
|
||||
// 1. Convert cell HTML to inline markdown text:
|
||||
// - Formatting (bold, italic, code) -> Markdown tokens
|
||||
// - Line breaks -> '<br>'
|
||||
// - Escape pipe symbols -> '\|'
|
||||
// 2. If beautifying (compact = false):
|
||||
// - Calculate the maximum string length of each column's cells (including headers)
|
||||
// - Pad each cell with spaces to match the maximum length
|
||||
// 3. Format header row
|
||||
// 4. Format separator row based on alignments with matching padding:
|
||||
// - Left: :--- or :--- [padding]
|
||||
// - Center: :---: or :---: [padding]
|
||||
// - Right: ---: or [padding] ---:
|
||||
// 5. Format body rows
|
||||
// 6. Join all rows with newlines
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. UI Layout
|
||||
|
||||
The UI will follow the premium dark/light mode aesthetics of `my-it-tools` using Naive UI controls:
|
||||
|
||||
1. **Toolbar:**
|
||||
- **Table Settings:** Preset size dialog, Clear, Transpose.
|
||||
- **Column Options:** Left/Center/Right Align. Insert Left, Insert Right, Delete Column.
|
||||
- **Row Options:** Insert Above, Insert Below, Delete Row.
|
||||
- **Undo/Redo:** Back and Forward history buttons.
|
||||
2. **Table Editor Grid:**
|
||||
- Styled HTML `<table>` with borders.
|
||||
- Header cells and row index handles styled distinctively.
|
||||
- Each cell contains a `div` with `contenteditable="true"`.
|
||||
- Right-click handlers on column headers (th) and row indices (td.index-column) to show a context-sensitive `<n-dropdown>` context menu.
|
||||
- **Column context menu options:** Align Left, Align Center, Align Right, Insert Column Left, Insert Column Right, Delete Column.
|
||||
- **Row context menu options:** Insert Row Above, Insert Row Below, Delete Row.
|
||||
- Hover elements to show target add/remove action icons at the border of columns/rows.
|
||||
3. **Output & Preview:**
|
||||
- Standard GFM markdown output textarea with copy and download buttons.
|
||||
- Beautifully rendered tab for visual preview of the markdown.
|
||||
|
||||
---
|
||||
|
||||
## 6. Testing Strategy
|
||||
|
||||
1. **Unit Tests (`table-state.test.ts`):**
|
||||
- Test table initialization and resizing.
|
||||
- Test insertion & deletion of rows and columns at specific indexes.
|
||||
- Test column alignments.
|
||||
- Test CSV/TSV, HTML table, and GFM markdown pasting/parsing.
|
||||
- Test markdown translation (bold, italic, and escaping pipe symbols).
|
||||
- Test Undo/Redo history stack operations.
|
||||
2. **Component Tests (`table-to-markdown.test.ts`):**
|
||||
- Verify tool rendering, button clicks, and basic data binding.
|
||||
396
locales/en.yml
396
locales/en.yml
|
|
@ -1,394 +1,468 @@
|
|||
'404':
|
||||
notFound: 404 Not Found
|
||||
sorry: Sorry, this page does not seem to exist
|
||||
maybe: Maybe the cache is doing tricky things, try force-refreshing?
|
||||
backHome: Back home
|
||||
home:
|
||||
categories:
|
||||
newestTools: Newest tools
|
||||
favoriteTools: 'Your favorite tools'
|
||||
allTools: 'All the tools'
|
||||
favoritesDndToolTip: 'Drag and drop to reorder favorites'
|
||||
subtitle: 'Handy tools for developers'
|
||||
toggleMenu: 'Toggle menu'
|
||||
favoriteTools: Your favorite tools
|
||||
allTools: All the tools
|
||||
favoritesDndToolTip: Drag and drop to reorder favorites
|
||||
subtitle: Handy tools for developers
|
||||
toggleMenu: Toggle menu
|
||||
home: Home
|
||||
uiLib: 'UI Lib'
|
||||
support: 'Support IT-Tools development'
|
||||
buyMeACoffee: 'Buy me a coffee'
|
||||
uiLib: UI Lib
|
||||
support: Support IT-Tools development
|
||||
buyMeACoffee: Buy me a coffee
|
||||
follow:
|
||||
title: 'You like it-tools?'
|
||||
p1: 'Give us a star on'
|
||||
githubRepository: 'IT-Tools GitHub repository'
|
||||
p2: 'or follow us on'
|
||||
twitterXAccount: 'IT-Tools X account'
|
||||
thankYou: 'Thank you!'
|
||||
title: You like it-tools?
|
||||
p1: Give us a star on
|
||||
githubRepository: IT-Tools GitHub repository
|
||||
p2: or follow us on
|
||||
twitterXAccount: IT-Tools X account
|
||||
thankYou: Thank you!
|
||||
nav:
|
||||
github: 'GitHub repository'
|
||||
githubRepository: 'IT-Tools GitHub repository'
|
||||
twitterX: 'X account'
|
||||
twitterXAccount: 'IT Tools X account'
|
||||
about: 'About IT-Tools'
|
||||
aboutLabel: 'About'
|
||||
darkMode: 'Dark mode'
|
||||
lightMode: 'Light mode'
|
||||
mode: 'Toggle dark/light mode'
|
||||
github: GitHub repository
|
||||
githubRepository: IT-Tools GitHub repository
|
||||
twitterX: X account
|
||||
twitterXAccount: IT Tools X account
|
||||
about: About IT-Tools
|
||||
aboutLabel: About
|
||||
darkMode: Dark mode
|
||||
lightMode: Light mode
|
||||
mode: Toggle dark/light mode
|
||||
about:
|
||||
content: >
|
||||
# About IT-Tools
|
||||
|
||||
This wonderful website, made with ❤ by [Corentin Thomasset](https://corentin.tech?utm_source=it-tools&utm_medium=about) , aggregates useful tools for developer and people working in IT. If you find it useful, please feel free to share it to people you think may find it useful too and don't forget to bookmark it in your shortcut bar!
|
||||
This wonderful website, made with ❤ by [Corentin
|
||||
Thomasset](https://corentin.tech?utm_source=it-tools&utm_medium=about) ,
|
||||
aggregates useful tools for developer and people working in IT. If you find
|
||||
it useful, please feel free to share it to people you think may find it
|
||||
useful too and don't forget to bookmark it in your shortcut bar!
|
||||
|
||||
IT Tools is open-source (under the GPL-3.0 license) and free, and will always be, but it costs me money to host and renew the domain name. If you want to support my work, and encourage me to add more tools, please consider supporting by [sponsoring me](https://www.buymeacoffee.com/cthmsst).
|
||||
IT Tools is open-source (under the GPL-3.0 license) and free, and will
|
||||
always be, but it costs me money to host and renew the domain name. If you
|
||||
want to support my work, and encourage me to add more tools, please consider
|
||||
supporting by [sponsoring me](https://www.buymeacoffee.com/cthmsst).
|
||||
|
||||
## Technologies
|
||||
|
||||
IT Tools is made in Vue.js (Vue 3) with the the Naive UI component library and is hosted and continuously deployed by Vercel. Third-party open-source libraries are used in some tools, you may find the complete list in the [package.json](https://github.com/tienlx93/my-it-tools/blob/main/package.json) file of the repository.
|
||||
IT Tools is made in Vue.js (Vue 3) with the the Naive UI component library
|
||||
and is hosted and continuously deployed by Vercel. Third-party open-source
|
||||
libraries are used in some tools, you may find the complete list in the
|
||||
[package.json](https://github.com/tienlx93/my-it-tools/blob/main/package.json)
|
||||
file of the repository.
|
||||
|
||||
## Found a bug? A tool is missing?
|
||||
|
||||
If you need a tool that is currently not present here, and you think can be useful, you are welcome to submit a feature request in the [issues section](https://github.com/tienlx93/my-it-tools/issues/new/choose) in the GitHub repository.
|
||||
If you need a tool that is currently not present here, and you think can be
|
||||
useful, you are welcome to submit a feature request in the [issues
|
||||
section](https://github.com/tienlx93/my-it-tools/issues/new/choose) in the
|
||||
GitHub repository.
|
||||
|
||||
And if you found a bug, or something doesn't work as expected, please file a bug report in the [issues section](https://github.com/tienlx93/my-it-tools/issues/new/choose) in the GitHub repository.
|
||||
|
||||
404:
|
||||
notFound: '404 Not Found'
|
||||
sorry: 'Sorry, this page does not seem to exist'
|
||||
maybe: 'Maybe the cache is doing tricky things, try force-refreshing?'
|
||||
backHome: 'Back home'
|
||||
And if you found a bug, or something doesn't work as expected, please file a
|
||||
bug report in the [issues
|
||||
section](https://github.com/tienlx93/my-it-tools/issues/new/choose) in the
|
||||
GitHub repository.
|
||||
favoriteButton:
|
||||
remove: 'Remove from favorites'
|
||||
add: 'Add to favorites'
|
||||
remove: Remove from favorites
|
||||
add: Add to favorites
|
||||
toolCard:
|
||||
new: New
|
||||
search:
|
||||
label: Search
|
||||
tools:
|
||||
categories:
|
||||
favorite-tools: 'Your favorite tools'
|
||||
favorite-tools: Your favorite tools
|
||||
crypto: Crypto
|
||||
converter: Converter
|
||||
web: Web
|
||||
images and videos: 'Images & Videos'
|
||||
images and videos: Images & Videos
|
||||
development: Development
|
||||
network: Network
|
||||
math: Math
|
||||
measurement: Measurement
|
||||
text: Text
|
||||
data: Data
|
||||
|
||||
password-strength-analyser:
|
||||
title: Password strength analyser
|
||||
description: Discover the strength of your password with this client-side-only password strength analyser and crack time estimation tool.
|
||||
|
||||
description: >-
|
||||
Discover the strength of your password with this client-side-only password
|
||||
strength analyser and crack time estimation tool.
|
||||
chronometer:
|
||||
title: Chronometer
|
||||
description: Monitor the duration of a thing. Basically a chronometer with simple chronometer features.
|
||||
|
||||
description: >-
|
||||
Monitor the duration of a thing. Basically a chronometer with simple
|
||||
chronometer features.
|
||||
token-generator:
|
||||
title: Token generator
|
||||
description: Generate random string with the chars you want, uppercase or lowercase letters, numbers and/or symbols.
|
||||
|
||||
description: >-
|
||||
Generate random string with the chars you want, uppercase or lowercase
|
||||
letters, numbers and/or symbols.
|
||||
uppercase: Uppercase (ABC...)
|
||||
lowercase: Lowercase (abc...)
|
||||
numbers: Numbers (123...)
|
||||
symbols: Symbols (!-;...)
|
||||
length: Length
|
||||
tokenPlaceholder: 'The token...'
|
||||
tokenPlaceholder: The token...
|
||||
copied: Token copied to the clipboard
|
||||
button:
|
||||
copy: Copy
|
||||
refresh: Refresh
|
||||
percentage-calculator:
|
||||
title: Percentage calculator
|
||||
description: Easily calculate percentages from a value to another value, or from a percentage to a value.
|
||||
|
||||
description: >-
|
||||
Easily calculate percentages from a value to another value, or from a
|
||||
percentage to a value.
|
||||
svg-placeholder-generator:
|
||||
title: SVG placeholder generator
|
||||
description: Generate svg images to use as a placeholder in your applications.
|
||||
|
||||
json-to-csv:
|
||||
title: JSON to CSV
|
||||
description: Convert JSON to CSV with automatic header detection.
|
||||
|
||||
camera-recorder:
|
||||
title: Camera recorder
|
||||
description: Take a picture or record a video from your webcam or camera.
|
||||
|
||||
keycode-info:
|
||||
title: Keycode info
|
||||
description: Find the javascript keycode, code, location and modifiers of any pressed key.
|
||||
|
||||
description: >-
|
||||
Find the javascript keycode, code, location and modifiers of any pressed
|
||||
key.
|
||||
emoji-picker:
|
||||
title: Emoji picker
|
||||
description: Copy and paste emojis easily and get the unicode and code points value of each emoji.
|
||||
|
||||
description: >-
|
||||
Copy and paste emojis easily and get the unicode and code points value of
|
||||
each emoji.
|
||||
color-converter:
|
||||
title: Color converter
|
||||
description: Convert color between the different formats (hex, rgb, hsl and css name)
|
||||
|
||||
bcrypt:
|
||||
title: Bcrypt
|
||||
description: Hash and compare text string using bcrypt. Bcrypt is a password-hashing function based on the Blowfish cipher.
|
||||
|
||||
description: >-
|
||||
Hash and compare text string using bcrypt. Bcrypt is a password-hashing
|
||||
function based on the Blowfish cipher.
|
||||
crontab-generator:
|
||||
title: Crontab generator
|
||||
description: Validate and generate crontab and get the human-readable description of the cron schedule.
|
||||
|
||||
description: >-
|
||||
Validate and generate crontab and get the human-readable description of
|
||||
the cron schedule.
|
||||
http-status-codes:
|
||||
title: HTTP status codes
|
||||
description: The list of all HTTP status codes, their name, and their meaning.
|
||||
|
||||
sql-prettify:
|
||||
title: SQL prettify and format
|
||||
description: Format and prettify your SQL queries online (it supports various SQL dialects).
|
||||
|
||||
description: >-
|
||||
Format and prettify your SQL queries online (it supports various SQL
|
||||
dialects).
|
||||
benchmark-builder:
|
||||
title: Benchmark builder
|
||||
description: Easily compare execution time of tasks with this very simple online benchmark builder.
|
||||
|
||||
description: >-
|
||||
Easily compare execution time of tasks with this very simple online
|
||||
benchmark builder.
|
||||
git-memo:
|
||||
title: Git cheatsheet
|
||||
description: Git is a decentralized version management software. With this cheatsheet, you will have quick access to the most common git commands.
|
||||
|
||||
description: >-
|
||||
Git is a decentralized version management software. With this cheatsheet,
|
||||
you will have quick access to the most common git commands.
|
||||
slugify-string:
|
||||
title: Slugify string
|
||||
description: Make a string url, filename and id safe.
|
||||
|
||||
encryption:
|
||||
title: Encrypt / decrypt text
|
||||
description: Encrypt clear text and decrypt ciphertext using crypto algorithms like AES, TripleDES, Rabbit or RC4.
|
||||
|
||||
description: >-
|
||||
Encrypt clear text and decrypt ciphertext using crypto algorithms like
|
||||
AES, TripleDES, Rabbit or RC4.
|
||||
random-port-generator:
|
||||
title: Random port generator
|
||||
description: Generate random port numbers outside of the range of "known" ports (0-1023).
|
||||
|
||||
description: >-
|
||||
Generate random port numbers outside of the range of "known" ports
|
||||
(0-1023).
|
||||
yaml-prettify:
|
||||
title: YAML prettify and format
|
||||
description: Prettify your YAML string into a friendly, human-readable format.
|
||||
|
||||
eta-calculator:
|
||||
title: ETA calculator
|
||||
description: An ETA (Estimated Time of Arrival) calculator to determine the approximate end time of a task, for example, the end time and duration of a file download.
|
||||
|
||||
description: >-
|
||||
An ETA (Estimated Time of Arrival) calculator to determine the approximate
|
||||
end time of a task, for example, the end time and duration of a file
|
||||
download.
|
||||
roman-numeral-converter:
|
||||
title: Roman numeral converter
|
||||
description: Convert Roman numerals to numbers and convert numbers to Roman numerals.
|
||||
|
||||
hmac-generator:
|
||||
title: Hmac generator
|
||||
description: Computes a hash-based message authentication code (HMAC) using a secret key and your favorite hashing function.
|
||||
|
||||
description: >-
|
||||
Computes a hash-based message authentication code (HMAC) using a secret
|
||||
key and your favorite hashing function.
|
||||
bip39-generator:
|
||||
title: BIP39 passphrase generator
|
||||
description: Generate a BIP39 passphrase from an existing or random mnemonic, or get the mnemonic from the passphrase.
|
||||
|
||||
description: >-
|
||||
Generate a BIP39 passphrase from an existing or random mnemonic, or get
|
||||
the mnemonic from the passphrase.
|
||||
base64-file-converter:
|
||||
title: Base64 file converter
|
||||
description: Convert a string, file, or image into its base64 representation.
|
||||
|
||||
list-converter:
|
||||
title: List converter
|
||||
description: This tool can process column-based data and apply various changes (transpose, add prefix and suffix, reverse list, sort list, lowercase values, truncate values) to each row.
|
||||
|
||||
description: >-
|
||||
This tool can process column-based data and apply various changes
|
||||
(transpose, add prefix and suffix, reverse list, sort list, lowercase
|
||||
values, truncate values) to each row.
|
||||
base64-string-converter:
|
||||
title: Base64 string encoder/decoder
|
||||
description: Simply encode and decode strings into their base64 representation.
|
||||
|
||||
toml-to-yaml:
|
||||
title: TOML to YAML
|
||||
description: Parse and convert TOML to YAML.
|
||||
|
||||
math-evaluator:
|
||||
title: Math evaluator
|
||||
description: A calculator for evaluating mathematical expressions. You can use functions like sqrt, cos, sin, abs, etc.
|
||||
|
||||
description: >-
|
||||
A calculator for evaluating mathematical expressions. You can use
|
||||
functions like sqrt, cos, sin, abs, etc.
|
||||
json-to-yaml-converter:
|
||||
title: JSON to YAML converter
|
||||
description: Simply convert JSON to YAML with this online live converter.
|
||||
|
||||
url-parser:
|
||||
title: URL parser
|
||||
description: Parse a URL into its separate constituent parts (protocol, origin, params, port, username-password, ...)
|
||||
|
||||
description: >-
|
||||
Parse a URL into its separate constituent parts (protocol, origin, params,
|
||||
port, username-password, ...)
|
||||
iban-validator-and-parser:
|
||||
title: IBAN validator and parser
|
||||
description: Validate and parse IBAN numbers. Check if an IBAN is valid and get the country, BBAN, if it is a QR-IBAN and the IBAN friendly format.
|
||||
|
||||
description: >-
|
||||
Validate and parse IBAN numbers. Check if an IBAN is valid and get the
|
||||
country, BBAN, if it is a QR-IBAN and the IBAN friendly format.
|
||||
user-agent-parser:
|
||||
title: User-agent parser
|
||||
description: Detect and parse Browser, Engine, OS, CPU, and Device type/model from an user-agent string.
|
||||
|
||||
description: >-
|
||||
Detect and parse Browser, Engine, OS, CPU, and Device type/model from an
|
||||
user-agent string.
|
||||
numeronym-generator:
|
||||
title: Numeronym generator
|
||||
description: A numeronym is a word where a number is used to form an abbreviation. For example, "i18n" is a numeronym of "internationalization" where 18 stands for the number of letters between the first i and the last n in the word.
|
||||
|
||||
description: >-
|
||||
A numeronym is a word where a number is used to form an abbreviation. For
|
||||
example, "i18n" is a numeronym of "internationalization" where 18 stands
|
||||
for the number of letters between the first i and the last n in the word.
|
||||
case-converter:
|
||||
title: Case converter
|
||||
description: Transform the case of a string and choose between different formats
|
||||
|
||||
html-entities:
|
||||
title: Escape HTML entities
|
||||
description: Escape or unescape HTML entities (replace characters like <,>, &, " and \' with their HTML version)
|
||||
|
||||
description: >-
|
||||
Escape or unescape HTML entities (replace characters like <,>, &, " and \'
|
||||
with their HTML version)
|
||||
json-prettify:
|
||||
title: JSON prettify and format
|
||||
description: Prettify your JSON string into a friendly, human-readable format.
|
||||
|
||||
docker-run-to-docker-compose-converter:
|
||||
title: Docker run to Docker compose converter
|
||||
description: Transforms "docker run" commands into docker-compose files!
|
||||
|
||||
mac-address-lookup:
|
||||
title: MAC address lookup
|
||||
description: Find the vendor and manufacturer of a device by its MAC address.
|
||||
|
||||
mime-types:
|
||||
title: MIME types
|
||||
description: Convert MIME types to file extensions and vice-versa.
|
||||
|
||||
toml-to-json:
|
||||
title: TOML to JSON
|
||||
description: Parse and convert TOML to JSON.
|
||||
|
||||
lorem-ipsum-generator:
|
||||
title: Lorem ipsum generator
|
||||
description: Lorem ipsum is a placeholder text commonly used to demonstrate the visual form of a document or a typeface without relying on meaningful content
|
||||
|
||||
description: >-
|
||||
Lorem ipsum is a placeholder text commonly used to demonstrate the visual
|
||||
form of a document or a typeface without relying on meaningful content
|
||||
qrcode-generator:
|
||||
title: QR Code generator
|
||||
description: Generate and download a QR code for a URL (or just plain text), and customize the background and foreground colors.
|
||||
|
||||
description: >-
|
||||
Generate and download a QR code for a URL (or just plain text), and
|
||||
customize the background and foreground colors.
|
||||
wifi-qrcode-generator:
|
||||
title: WiFi QR Code generator
|
||||
description: Generate and download QR codes for quick connections to WiFi networks.
|
||||
|
||||
xml-formatter:
|
||||
title: XML formatter
|
||||
description: Prettify your XML string into a friendly, human-readable format.
|
||||
|
||||
temperature-converter:
|
||||
title: Temperature converter
|
||||
description: Degrees temperature conversions for Kelvin, Celsius, Fahrenheit, Rankine, Delisle, Newton, Réaumur, and Rømer.
|
||||
|
||||
description: >-
|
||||
Degrees temperature conversions for Kelvin, Celsius, Fahrenheit, Rankine,
|
||||
Delisle, Newton, Réaumur, and Rømer.
|
||||
chmod-calculator:
|
||||
title: Chmod calculator
|
||||
description: Compute your chmod permissions and commands with this online chmod calculator.
|
||||
|
||||
description: >-
|
||||
Compute your chmod permissions and commands with this online chmod
|
||||
calculator.
|
||||
rsa-key-pair-generator:
|
||||
title: RSA key pair generator
|
||||
description: Generate a new random RSA private and public pem certificate key pair.
|
||||
|
||||
html-wysiwyg-editor:
|
||||
title: HTML WYSIWYG editor
|
||||
description: Online, feature-rich WYSIWYG HTML editor which generates the source code of the content immediately.
|
||||
|
||||
description: >-
|
||||
Online, feature-rich WYSIWYG HTML editor which generates the source code
|
||||
of the content immediately.
|
||||
yaml-to-toml:
|
||||
title: YAML to TOML
|
||||
description: Parse and convert YAML to TOML.
|
||||
|
||||
mac-address-generator:
|
||||
title: MAC address generator
|
||||
description: Enter the quantity and prefix. MAC addresses will be generated in your chosen case (uppercase or lowercase)
|
||||
|
||||
description: >-
|
||||
Enter the quantity and prefix. MAC addresses will be generated in your
|
||||
chosen case (uppercase or lowercase)
|
||||
json-diff:
|
||||
title: JSON diff
|
||||
description: Compare two JSON objects and get the differences between them.
|
||||
|
||||
jwt-parser:
|
||||
title: JWT parser
|
||||
description: Parse and decode your JSON Web Token (jwt) and display its content.
|
||||
|
||||
date-converter:
|
||||
title: Date-time converter
|
||||
description: Convert date and time into the various different formats
|
||||
|
||||
phone-parser-and-formatter:
|
||||
title: Phone parser and formatter
|
||||
description: Parse, validate and format phone numbers. Get information about the phone number, like the country code, type, etc.
|
||||
|
||||
description: >-
|
||||
Parse, validate and format phone numbers. Get information about the phone
|
||||
number, like the country code, type, etc.
|
||||
ipv4-subnet-calculator:
|
||||
title: IPv4 subnet calculator
|
||||
description: Parse your IPv4 CIDR blocks and get all the info you need about your subnet.
|
||||
|
||||
description: >-
|
||||
Parse your IPv4 CIDR blocks and get all the info you need about your
|
||||
subnet.
|
||||
og-meta-generator:
|
||||
title: Open graph meta generator
|
||||
description: Generate open-graph and socials HTML meta tags for your website.
|
||||
|
||||
ipv6-ula-generator:
|
||||
title: IPv6 ULA generator
|
||||
description: Generate your own local, non-routable IP addresses for your network according to RFC4193.
|
||||
|
||||
description: >-
|
||||
Generate your own local, non-routable IP addresses for your network
|
||||
according to RFC4193.
|
||||
hash-text:
|
||||
title: Hash text
|
||||
description: 'Hash a text string using the function you need : MD5, SHA1, SHA256, SHA224, SHA512, SHA384, SHA3 or RIPEMD160'
|
||||
|
||||
description: >-
|
||||
Hash a text string using the function you need : MD5, SHA1, SHA256,
|
||||
SHA224, SHA512, SHA384, SHA3 or RIPEMD160
|
||||
json-to-toml:
|
||||
title: JSON to TOML
|
||||
description: Parse and convert JSON to TOML.
|
||||
|
||||
device-information:
|
||||
title: Device information
|
||||
description: Get information about your current device (screen size, pixel-ratio, user agent, ...)
|
||||
|
||||
description: >-
|
||||
Get information about your current device (screen size, pixel-ratio, user
|
||||
agent, ...)
|
||||
pdf-signature-checker:
|
||||
title: PDF signature checker
|
||||
description: Verify the signatures of a PDF file. A signed PDF file contains one or more signatures that may be used to determine whether the contents of the file have been altered since the file was signed.
|
||||
|
||||
description: >-
|
||||
Verify the signatures of a PDF file. A signed PDF file contains one or
|
||||
more signatures that may be used to determine whether the contents of the
|
||||
file have been altered since the file was signed.
|
||||
json-minify:
|
||||
title: JSON minify
|
||||
description: Minify and compress your JSON by removing unnecessary whitespace.
|
||||
|
||||
ulid-generator:
|
||||
title: ULID generator
|
||||
description: Generate random Universally Unique Lexicographically Sortable Identifier (ULID).
|
||||
|
||||
description: >-
|
||||
Generate random Universally Unique Lexicographically Sortable Identifier
|
||||
(ULID).
|
||||
string-obfuscator:
|
||||
title: String obfuscator
|
||||
description: Obfuscate a string (like a secret, an IBAN, or a token) to make it shareable and identifiable without revealing its content.
|
||||
|
||||
description: >-
|
||||
Obfuscate a string (like a secret, an IBAN, or a token) to make it
|
||||
shareable and identifiable without revealing its content.
|
||||
base-converter:
|
||||
title: Integer base converter
|
||||
description: Convert a number between different bases (decimal, hexadecimal, binary, octal, base64, ...)
|
||||
|
||||
description: >-
|
||||
Convert a number between different bases (decimal, hexadecimal, binary,
|
||||
octal, base64, ...)
|
||||
yaml-to-json-converter:
|
||||
title: YAML to JSON converter
|
||||
description: Simply convert YAML to JSON with this online live converter.
|
||||
|
||||
uuid-generator:
|
||||
title: UUIDs generator
|
||||
description: A Universally Unique Identifier (UUID) is a 128-bit number used to identify information in computer systems. The number of possible UUIDs is 16^32, which is 2^128 or about 3.4x10^38 (which is a lot!).
|
||||
|
||||
description: >-
|
||||
A Universally Unique Identifier (UUID) is a 128-bit number used to
|
||||
identify information in computer systems. The number of possible UUIDs is
|
||||
16^32, which is 2^128 or about 3.4x10^38 (which is a lot!).
|
||||
ipv4-address-converter:
|
||||
title: IPv4 address converter
|
||||
description: Convert an IP address into decimal, binary, hexadecimal, or even an IPv6 representation of it.
|
||||
|
||||
description: >-
|
||||
Convert an IP address into decimal, binary, hexadecimal, or even an IPv6
|
||||
representation of it.
|
||||
text-statistics:
|
||||
title: Text statistics
|
||||
description: Get information about a text, the number of characters, the number of words, its size in bytes, ...
|
||||
|
||||
description: >-
|
||||
Get information about a text, the number of characters, the number of
|
||||
words, its size in bytes, ...
|
||||
text-to-nato-alphabet:
|
||||
title: Text to NATO alphabet
|
||||
description: Transform text into the NATO phonetic alphabet for oral transmission.
|
||||
|
||||
basic-auth-generator:
|
||||
title: Basic auth generator
|
||||
description: Generate a base64 basic auth header from a username and password.
|
||||
|
||||
text-to-unicode:
|
||||
title: Text to Unicode
|
||||
description: Parse and convert text to unicode and vice-versa
|
||||
|
||||
ipv4-range-expander:
|
||||
title: IPv4 range expander
|
||||
description: Given a start and an end IPv4 address, this tool calculates a valid IPv4 subnet along with its CIDR notation.
|
||||
|
||||
description: >-
|
||||
Given a start and an end IPv4 address, this tool calculates a valid IPv4
|
||||
subnet along with its CIDR notation.
|
||||
text-diff:
|
||||
title: Text diff
|
||||
description: Compare two texts and see the differences between them.
|
||||
|
||||
otp-generator:
|
||||
title: OTP code generator
|
||||
description: Generate and validate time-based OTP (one time password) for multi-factor authentication.
|
||||
|
||||
description: >-
|
||||
Generate and validate time-based OTP (one time password) for multi-factor
|
||||
authentication.
|
||||
url-encoder:
|
||||
title: Encode/decode URL-formatted strings
|
||||
description: Encode text to URL-encoded format (also known as "percent-encoded"), or decode from it.
|
||||
|
||||
description: >-
|
||||
Encode text to URL-encoded format (also known as "percent-encoded"), or
|
||||
decode from it.
|
||||
text-to-binary:
|
||||
title: Text to ASCII binary
|
||||
description: Convert text to its ASCII binary representation and vice-versa.
|
||||
html-to-markdown:
|
||||
title: HTML to markdown
|
||||
description: Convert HTML (either from clipboard) to Markdown
|
||||
texts:
|
||||
placeholder-your-html-content: Your HTML content...
|
||||
label-your-html-to-convert-can-paste-from-clipboard: 'Your HTML to convert (can paste from clipboard):'
|
||||
label-output-markdown: 'Output markdown:'
|
||||
label-heading-style: 'Headings Style:'
|
||||
label-emphasis-style: 'Emphasis Style:'
|
||||
label-hash: Hash (#)
|
||||
label-underline: Underline (=/-)
|
||||
label-surround-heading: Surround (= Heading =)
|
||||
label-asterisk: Asterisk (*)
|
||||
label-underscore: Underscore (_)
|
||||
label-tilde: Tilde (~)
|
||||
table-to-markdown:
|
||||
title: Table to markdown generator
|
||||
description: Create, edit, paste spreadsheet tables and export to GFM Markdown.
|
||||
alignLeft: Align left
|
||||
alignCenter: Align center
|
||||
alignRight: Align right
|
||||
insertColLeft: Insert column left
|
||||
insertColRight: Insert column right
|
||||
deleteCol: Delete column
|
||||
insertRowAbove: Insert row above
|
||||
insertRowBelow: Insert row below
|
||||
deleteRow: Delete row
|
||||
newTable: New Table
|
||||
transpose: Transpose
|
||||
clear: Clear
|
||||
undo: Undo
|
||||
redo: Redo
|
||||
compactMode: Compact Mode
|
||||
markdownOutput: Markdown Output
|
||||
visualPreview: Visual Preview
|
||||
rows: Rows
|
||||
columns: Columns
|
||||
cancel: Cancel
|
||||
create: Create
|
||||
newTableTitle: Create New Table
|
||||
texts:
|
||||
new-table: New table...
|
||||
markdown-result: 'Markdown result:'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { tool as base64FileConverter } from './base64-file-converter';
|
||||
import { tool as base64StringConverter } from './base64-string-converter';
|
||||
import { tool as basicAuthGenerator } from './basic-auth-generator';
|
||||
import { tool as htmlToMarkdown } from './html-to-markdown';
|
||||
import { tool as tableToMarkdown } from './table-to-markdown';
|
||||
import { tool as emailNormalizer } from './email-normalizer';
|
||||
|
||||
import { tool as asciiTextDrawer } from './ascii-text-drawer';
|
||||
|
|
@ -166,6 +168,10 @@ export const toolsByCategory: ToolCategory[] = [
|
|||
name: 'Network',
|
||||
components: [ipv4SubnetCalculator, ipv4AddressConverter, ipv4RangeExpander, macAddressLookup, macAddressGenerator, ipv6UlaGenerator],
|
||||
},
|
||||
{
|
||||
name: 'Markdown',
|
||||
components: [htmlToMarkdown, tableToMarkdown],
|
||||
},
|
||||
{
|
||||
name: 'Math',
|
||||
components: [mathEvaluator, etaCalculator, percentageCalculator],
|
||||
|
|
|
|||
14
src/tools/table-to-markdown/index.ts
Normal file
14
src/tools/table-to-markdown/index.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { Table } from '@vicons/tabler';
|
||||
import { defineTool } from '../tool';
|
||||
import { translate as t } from '@/plugins/i18n.plugin';
|
||||
|
||||
export const tool = defineTool({
|
||||
name: t('tools.table-to-markdown.title'),
|
||||
path: '/table-to-markdown',
|
||||
description: t('tools.table-to-markdown.description'),
|
||||
keywords: ['table', 'markdown', 'generator', 'editor', 'csv', 'excel'],
|
||||
component: () => import('./table-to-markdown.vue'),
|
||||
icon: Table,
|
||||
createdAt: new Date('2026-07-08'),
|
||||
category: 'Markdown',
|
||||
});
|
||||
482
src/tools/table-to-markdown/table-editor.vue
Normal file
482
src/tools/table-to-markdown/table-editor.vue
Normal file
|
|
@ -0,0 +1,482 @@
|
|||
<!-- eslint-disable vue/no-mutating-props -->
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { TableState } from './table-state';
|
||||
|
||||
const props = defineProps<{
|
||||
state: TableState
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
|
||||
// Dropdown/Context Menu State
|
||||
const showDropdown = ref(false);
|
||||
const x = ref(0);
|
||||
const y = ref(0);
|
||||
const dropdownType = ref<'column' | 'row' | null>(null);
|
||||
const targetIndex = ref<number>(-1);
|
||||
|
||||
// Focused Cell Tracking State
|
||||
const focusedCell = ref<{
|
||||
type: 'header' | 'body'
|
||||
rowIdx?: number
|
||||
colIdx: number
|
||||
} | null>(null);
|
||||
|
||||
const initialCellHtml = ref<string>('');
|
||||
|
||||
function isCellFocused(type: 'header' | 'body', rowIdx: number | undefined, colIdx: number): boolean {
|
||||
if (!focusedCell.value) {
|
||||
return false;
|
||||
}
|
||||
if (focusedCell.value.type !== type) {
|
||||
return false;
|
||||
}
|
||||
if (focusedCell.value.colIdx !== colIdx) {
|
||||
return false;
|
||||
}
|
||||
if (type === 'body') {
|
||||
return focusedCell.value.rowIdx === rowIdx;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Selection options based on right-clicked header or row index
|
||||
const dropdownOptions = computed(() => {
|
||||
if (dropdownType.value === 'column') {
|
||||
return [
|
||||
{ label: t('tools.table-to-markdown.alignLeft'), key: 'align-left' },
|
||||
{ label: t('tools.table-to-markdown.alignCenter'), key: 'align-center' },
|
||||
{ label: t('tools.table-to-markdown.alignRight'), key: 'align-right' },
|
||||
{ key: 'd1', type: 'divider' },
|
||||
{ label: t('tools.table-to-markdown.insertColLeft'), key: 'insert-col-left' },
|
||||
{ label: t('tools.table-to-markdown.insertColRight'), key: 'insert-col-right' },
|
||||
{ key: 'd2', type: 'divider' },
|
||||
{
|
||||
label: t('tools.table-to-markdown.deleteCol'),
|
||||
key: 'delete-col',
|
||||
disabled: props.state.headers.length <= 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
else if (dropdownType.value === 'row') {
|
||||
return [
|
||||
{ label: t('tools.table-to-markdown.insertRowAbove'), key: 'insert-row-above' },
|
||||
{ label: t('tools.table-to-markdown.insertRowBelow'), key: 'insert-row-below' },
|
||||
{ key: 'd3', type: 'divider' },
|
||||
{
|
||||
label: t('tools.table-to-markdown.deleteRow'),
|
||||
key: 'delete-row',
|
||||
disabled: props.state.rows.length <= 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
// Focus helper using DOM query inside table container
|
||||
function focusCell(type: 'header' | 'body', rIdx: number | undefined, cIdx: number) {
|
||||
let selector = '';
|
||||
if (type === 'header') {
|
||||
selector = `[data-cell-type="header"][data-col="${cIdx}"]`;
|
||||
}
|
||||
else {
|
||||
selector = `[data-cell-type="body"][data-row="${rIdx}"][data-col="${cIdx}"]`;
|
||||
}
|
||||
|
||||
const el = containerRef.value?.querySelector(selector) as HTMLElement | null;
|
||||
if (el) {
|
||||
el.focus();
|
||||
try {
|
||||
const range = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
range.selectNodeContents(el);
|
||||
range.collapse(false); // Move caret to the end
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Error setting caret selection:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handlers for focus and blur
|
||||
function onCellFocus(type: 'header' | 'body', rowIdx: number | undefined, colIdx: number, event: FocusEvent) {
|
||||
focusedCell.value = { type, rowIdx, colIdx };
|
||||
const target = event.target as HTMLElement;
|
||||
initialCellHtml.value = target.innerHTML;
|
||||
}
|
||||
|
||||
function onCellBlur(type: 'header' | 'body', rowIdx: number | undefined, colIdx: number, event: FocusEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
const currentHtml = target.innerHTML;
|
||||
|
||||
if (currentHtml !== initialCellHtml.value) {
|
||||
props.state.saveHistory();
|
||||
}
|
||||
|
||||
if (focusedCell.value && focusedCell.value.type === type && focusedCell.value.colIdx === colIdx) {
|
||||
if (type === 'header' || focusedCell.value.rowIdx === rowIdx) {
|
||||
focusedCell.value = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handler for keystroke input
|
||||
function onCellInput(type: 'header' | 'body', rowIdx: number | undefined, colIdx: number, event: Event) {
|
||||
const target = event.target as HTMLElement;
|
||||
const newHtml = target.innerHTML;
|
||||
if (type === 'header') {
|
||||
props.state.headers[colIdx].html = newHtml;
|
||||
}
|
||||
else {
|
||||
props.state.rows[rowIdx!][colIdx].html = newHtml;
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard Shortcuts override
|
||||
function onCellKeydown(event: KeyboardEvent, type: 'header' | 'body', rowIdx: number | undefined, colIdx: number) {
|
||||
if (event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) {
|
||||
// Shift + Tab (navigate backward)
|
||||
if (type === 'header') {
|
||||
if (colIdx > 0) {
|
||||
focusCell('header', undefined, colIdx - 1);
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (colIdx > 0) {
|
||||
focusCell('body', rowIdx, colIdx - 1);
|
||||
}
|
||||
else {
|
||||
if (rowIdx! > 0) {
|
||||
focusCell('body', rowIdx! - 1, props.state.headers.length - 1);
|
||||
}
|
||||
else {
|
||||
focusCell('header', undefined, props.state.headers.length - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Tab (navigate forward)
|
||||
if (type === 'header') {
|
||||
if (colIdx < props.state.headers.length - 1) {
|
||||
focusCell('header', undefined, colIdx + 1);
|
||||
}
|
||||
else if (props.state.rows.length > 0) {
|
||||
focusCell('body', 0, 0);
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (colIdx < props.state.headers.length - 1) {
|
||||
focusCell('body', rowIdx, colIdx + 1);
|
||||
}
|
||||
else {
|
||||
if (rowIdx! < props.state.rows.length - 1) {
|
||||
focusCell('body', rowIdx! + 1, 0);
|
||||
}
|
||||
else {
|
||||
props.state.insertRow(rowIdx!, 'below');
|
||||
nextTick(() => {
|
||||
focusCell('body', rowIdx! + 1, 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (event.key === 'Enter') {
|
||||
if (!event.shiftKey) {
|
||||
event.preventDefault();
|
||||
// Enter (navigate to cell below)
|
||||
if (type === 'header') {
|
||||
if (props.state.rows.length > 0) {
|
||||
focusCell('body', 0, colIdx);
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (rowIdx! < props.state.rows.length - 1) {
|
||||
focusCell('body', rowIdx! + 1, colIdx);
|
||||
}
|
||||
else {
|
||||
props.state.insertRow(rowIdx!, 'below');
|
||||
nextTick(() => {
|
||||
focusCell('body', rowIdx! + 1, colIdx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Shift + Enter is allowed to insert visual <br> (default browser action)
|
||||
}
|
||||
}
|
||||
|
||||
// Context Menu Handlers
|
||||
function onHeaderContextMenu(event: MouseEvent, colIdx: number) {
|
||||
showDropdown.value = false;
|
||||
dropdownType.value = 'column';
|
||||
targetIndex.value = colIdx;
|
||||
nextTick(() => {
|
||||
x.value = event.clientX;
|
||||
y.value = event.clientY;
|
||||
showDropdown.value = true;
|
||||
});
|
||||
}
|
||||
|
||||
// Context Menu Handlers for Row Indexes
|
||||
function onRowContextMenu(event: MouseEvent, rowIdx: number) {
|
||||
showDropdown.value = false;
|
||||
dropdownType.value = 'row';
|
||||
targetIndex.value = rowIdx;
|
||||
nextTick(() => {
|
||||
x.value = event.clientX;
|
||||
y.value = event.clientY;
|
||||
showDropdown.value = true;
|
||||
});
|
||||
}
|
||||
|
||||
function handleSelect(key: string) {
|
||||
showDropdown.value = false;
|
||||
const idx = targetIndex.value;
|
||||
if (idx === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (dropdownType.value === 'column') {
|
||||
if (key === 'align-left') {
|
||||
props.state.setColumnAlignment(idx, 'left');
|
||||
}
|
||||
else if (key === 'align-center') {
|
||||
props.state.setColumnAlignment(idx, 'center');
|
||||
}
|
||||
else if (key === 'align-right') {
|
||||
props.state.setColumnAlignment(idx, 'right');
|
||||
}
|
||||
else if (key === 'insert-col-left') {
|
||||
props.state.insertColumn(idx, 'left');
|
||||
}
|
||||
else if (key === 'insert-col-right') {
|
||||
props.state.insertColumn(idx, 'right');
|
||||
}
|
||||
else if (key === 'delete-col') {
|
||||
props.state.deleteColumn(idx);
|
||||
}
|
||||
}
|
||||
else if (dropdownType.value === 'row') {
|
||||
if (key === 'insert-row-above') {
|
||||
props.state.insertRow(idx, 'above');
|
||||
}
|
||||
else if (key === 'insert-row-below') {
|
||||
props.state.insertRow(idx, 'below');
|
||||
}
|
||||
else if (key === 'delete-row') {
|
||||
props.state.deleteRow(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global Grid Paste Handling
|
||||
function onTablePaste(event: ClipboardEvent) {
|
||||
const html = event.clipboardData?.getData('text/html') || '';
|
||||
const text = event.clipboardData?.getData('text/plain') || '';
|
||||
|
||||
// Intercept paste if it contains a table structure, TSV tabs or row newlines
|
||||
const isTablePaste = html.includes('<table') || text.includes('\t') || text.includes('\n');
|
||||
|
||||
if (isTablePaste) {
|
||||
event.preventDefault();
|
||||
props.state.parsePaste(html, text);
|
||||
// Blur to refresh active inputs
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom directive to update innerHTML without resetting cursor selection during active typing
|
||||
const vSafeHtml = {
|
||||
mounted(el: HTMLElement, binding: any) {
|
||||
el.innerHTML = binding.value ?? '';
|
||||
},
|
||||
updated(el: HTMLElement, binding: any) {
|
||||
if (binding.value !== binding.oldValue) {
|
||||
if (document.activeElement !== el) {
|
||||
el.innerHTML = binding.value ?? '';
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="table-editor-container" @paste="onTablePaste">
|
||||
<table class="table-editor">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="row-index-header">
|
||||
#
|
||||
</th>
|
||||
<!-- Contenteditable column headers -->
|
||||
<th
|
||||
v-for="(header, colIdx) in state.headers"
|
||||
:key="`h-${colIdx}`"
|
||||
v-safe-html="header.html"
|
||||
contenteditable="true"
|
||||
data-cell-type="header"
|
||||
:data-col="colIdx"
|
||||
:style="{ textAlign: state.alignments[colIdx] || 'left' }"
|
||||
:class="{ 'is-focused': isCellFocused('header', undefined, colIdx) }"
|
||||
@focus="onCellFocus('header', undefined, colIdx, $event)"
|
||||
@blur="onCellBlur('header', undefined, colIdx, $event)"
|
||||
@input="onCellInput('header', undefined, colIdx, $event)"
|
||||
@keydown="onCellKeydown($event, 'header', undefined, colIdx)"
|
||||
@contextmenu.prevent="onHeaderContextMenu($event, colIdx)"
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, rowIdx) in state.rows" :key="`r-${rowIdx}`">
|
||||
<!-- Row index cell containing row number -->
|
||||
<td
|
||||
class="row-index-cell"
|
||||
@contextmenu.prevent="onRowContextMenu($event, rowIdx)"
|
||||
>
|
||||
{{ rowIdx + 1 }}
|
||||
</td>
|
||||
<!-- Contenteditable body cells -->
|
||||
<td
|
||||
v-for="(cell, colIdx) in row"
|
||||
:key="`c-${colIdx}`"
|
||||
v-safe-html="cell.html"
|
||||
contenteditable="true"
|
||||
data-cell-type="body"
|
||||
:data-row="rowIdx"
|
||||
:data-col="colIdx"
|
||||
:style="{ textAlign: state.alignments[colIdx] || 'left' }"
|
||||
:class="{ 'is-focused': isCellFocused('body', rowIdx, colIdx) }"
|
||||
@focus="onCellFocus('body', rowIdx, colIdx, $event)"
|
||||
@blur="onCellBlur('body', rowIdx, colIdx, $event)"
|
||||
@input="onCellInput('body', rowIdx, colIdx, $event)"
|
||||
@keydown="onCellKeydown($event, 'body', rowIdx, colIdx)"
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<n-dropdown
|
||||
placement="bottom-start"
|
||||
trigger="manual"
|
||||
:x="x"
|
||||
:y="y"
|
||||
:show="showDropdown"
|
||||
:options="dropdownOptions"
|
||||
@clickoutside="showDropdown = false"
|
||||
@select="handleSelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
.table-editor-container {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background-color: var(--cell-bg);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
margin: 16px 0;
|
||||
width: 100%;
|
||||
|
||||
--border-color: #efeff5;
|
||||
--header-bg: #fafafc;
|
||||
--index-bg: #f5f5f7;
|
||||
--cell-bg: #ffffff;
|
||||
--text-color: #333639;
|
||||
--index-text-color: #8e8e93;
|
||||
--primary-color: #18a058;
|
||||
}
|
||||
|
||||
.dark .table-editor-container {
|
||||
--border-color: #303033;
|
||||
--header-bg: #18181c;
|
||||
--index-bg: #101014;
|
||||
--cell-bg: #18181c;
|
||||
--text-color: #d7dae2;
|
||||
--index-text-color: #767c82;
|
||||
--primary-color: #18a058;
|
||||
}
|
||||
|
||||
.table-editor {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
color: var(--text-color);
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 12px 16px;
|
||||
min-width: 100px;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: var(--header-bg);
|
||||
font-weight: 600;
|
||||
outline: none;
|
||||
|
||||
&[contenteditable="true"]:focus {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px var(--primary-color);
|
||||
background-color: rgba(24, 160, 88, 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
outline: none;
|
||||
|
||||
&[contenteditable="true"]:focus {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px var(--primary-color);
|
||||
background-color: rgba(24, 160, 88, 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
.row-index-header {
|
||||
background-color: var(--index-bg);
|
||||
color: var(--index-text-color);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
width: 45px;
|
||||
min-width: 45px;
|
||||
max-width: 45px;
|
||||
cursor: default;
|
||||
border-right: 2px solid var(--border-color);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.row-index-cell {
|
||||
background-color: var(--index-bg);
|
||||
color: var(--index-text-color);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
width: 45px;
|
||||
min-width: 45px;
|
||||
max-width: 45px;
|
||||
cursor: context-menu;
|
||||
user-select: none;
|
||||
border-right: 2px solid var(--border-color);
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(24, 160, 88, 0.08);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
156
src/tools/table-to-markdown/table-state.test.ts
Normal file
156
src/tools/table-to-markdown/table-state.test.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { TableState } from './table-state';
|
||||
|
||||
describe('TableState Core Operations', () => {
|
||||
it('initializes grid with correct dimensions', () => {
|
||||
const state = new TableState(3, 4);
|
||||
expect(state.headers.length).toBe(4);
|
||||
expect(state.rows.length).toBe(3);
|
||||
expect(state.rows[0].length).toBe(4);
|
||||
expect(state.alignments.length).toBe(4);
|
||||
});
|
||||
|
||||
it('can set column alignment', () => {
|
||||
const state = new TableState(3, 3);
|
||||
state.setColumnAlignment(1, 'center');
|
||||
expect(state.alignments[1]).toBe('center');
|
||||
});
|
||||
|
||||
it('can insert row and column', () => {
|
||||
const state = new TableState(2, 2);
|
||||
state.insertRow(1, 'below');
|
||||
expect(state.rows.length).toBe(3);
|
||||
|
||||
state.insertColumn(0, 'right');
|
||||
expect(state.headers.length).toBe(3);
|
||||
expect(state.rows[0].length).toBe(3);
|
||||
});
|
||||
|
||||
it('can delete row and column', () => {
|
||||
const state = new TableState(3, 3);
|
||||
state.deleteRow(1);
|
||||
expect(state.rows.length).toBe(2);
|
||||
|
||||
state.deleteColumn(1);
|
||||
expect(state.headers.length).toBe(2);
|
||||
expect(state.rows[0].length).toBe(2);
|
||||
});
|
||||
|
||||
it('can transpose a table', () => {
|
||||
const state = new TableState(2, 3); // 2 rows, 3 cols
|
||||
state.headers[0].html = 'H1';
|
||||
state.headers[1].html = 'H2';
|
||||
state.headers[2].html = 'H3';
|
||||
state.rows[0][0].html = 'A';
|
||||
state.rows[1][0].html = 'B';
|
||||
state.transpose();
|
||||
expect(state.headers.length).toBe(3);
|
||||
expect(state.rows.length).toBe(2);
|
||||
expect(state.rows[0].length).toBe(3);
|
||||
});
|
||||
|
||||
it('handles Undo and Redo states', () => {
|
||||
const state = new TableState(2, 2);
|
||||
state.saveHistory();
|
||||
state.rows[0][0].html = 'Edited';
|
||||
state.saveHistory();
|
||||
state.undo();
|
||||
expect(state.rows[0][0].html).toBe('');
|
||||
state.redo();
|
||||
expect(state.rows[0][0].html).toBe('Edited');
|
||||
});
|
||||
|
||||
it('allows undoing the first mutation', () => {
|
||||
const state = new TableState(2, 2);
|
||||
state.setColumnAlignment(1, 'center');
|
||||
expect(state.alignments[1]).toBe('center');
|
||||
state.undo();
|
||||
expect(state.alignments[1]).toBeNull();
|
||||
});
|
||||
|
||||
it('does not mutate or save history if indices are out of bounds', () => {
|
||||
const state = new TableState(2, 2);
|
||||
|
||||
// deleteRow with out of bounds index
|
||||
state.deleteRow(-1);
|
||||
expect(state.rows.length).toBe(2);
|
||||
|
||||
state.deleteRow(5);
|
||||
expect(state.rows.length).toBe(2);
|
||||
|
||||
// deleteColumn with out of bounds index
|
||||
state.deleteColumn(-1);
|
||||
expect(state.headers.length).toBe(2);
|
||||
|
||||
state.deleteColumn(5);
|
||||
expect(state.headers.length).toBe(2);
|
||||
|
||||
// insertRow with out of bounds index
|
||||
state.insertRow(-1, 'above');
|
||||
expect(state.rows.length).toBe(2);
|
||||
|
||||
state.insertRow(5, 'above');
|
||||
expect(state.rows.length).toBe(2);
|
||||
|
||||
// insertColumn with out of bounds index
|
||||
state.insertColumn(-1, 'left');
|
||||
expect(state.headers.length).toBe(2);
|
||||
|
||||
state.insertColumn(5, 'left');
|
||||
expect(state.headers.length).toBe(2);
|
||||
|
||||
// setColumnAlignment with out of bounds index
|
||||
state.setColumnAlignment(-1, 'center');
|
||||
expect(state.alignments).toEqual([null, null]);
|
||||
|
||||
state.setColumnAlignment(5, 'center');
|
||||
expect(state.alignments).toEqual([null, null]);
|
||||
});
|
||||
|
||||
describe('Markdown Conversion & Parsing', () => {
|
||||
it('serializes cell styles to GFM Markdown with formatting', () => {
|
||||
const state = new TableState(1, 2);
|
||||
state.headers[0].html = '<b>Header 1</b>';
|
||||
state.headers[1].html = 'Header 2';
|
||||
state.rows[0][0].html = '<i>Italic</i>';
|
||||
state.rows[0][1].html = 'Line 1<br>Line 2';
|
||||
|
||||
const markdown = state.toMarkdown({ compact: true });
|
||||
expect(markdown).toContain('|**Header 1**|Header 2|');
|
||||
expect(markdown).toContain('|*Italic*|Line 1<br>Line 2|');
|
||||
});
|
||||
|
||||
it('generates padded Markdown in beautified mode', () => {
|
||||
const state = new TableState(1, 2);
|
||||
state.headers[0].html = 'H1';
|
||||
state.headers[1].html = 'LongHeader';
|
||||
state.rows[0][0].html = 'Value';
|
||||
state.rows[0][1].html = 'V';
|
||||
|
||||
const markdown = state.toMarkdown({ compact: false });
|
||||
expect(markdown).toContain('| H1 | LongHeader |');
|
||||
});
|
||||
|
||||
it('parses Excel HTML paste tables', () => {
|
||||
const excelHtml = `
|
||||
<table>
|
||||
<tr><th>Col A</th><th>Col B</th></tr>
|
||||
<tr><td>A1</td><td><b>B1</b></td></tr>
|
||||
</table>
|
||||
`;
|
||||
const state = new TableState(1, 1);
|
||||
state.parsePaste(excelHtml, '');
|
||||
expect(state.headers.length).toBe(2);
|
||||
expect(state.headers[0].html).toBe('Col A');
|
||||
expect(state.rows[0][1].html).toContain('<b>B1</b>');
|
||||
});
|
||||
|
||||
it('parses CSV/TSV plain text paste', () => {
|
||||
const plainText = 'Col A\tCol B\nA1\tB1';
|
||||
const state = new TableState(1, 1);
|
||||
state.parsePaste('', plainText);
|
||||
expect(state.headers.length).toBe(2);
|
||||
expect(state.rows[0][0].html).toBe('A1');
|
||||
});
|
||||
});
|
||||
});
|
||||
332
src/tools/table-to-markdown/table-state.ts
Normal file
332
src/tools/table-to-markdown/table-state.ts
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
export interface Cell {
|
||||
html: string
|
||||
}
|
||||
|
||||
export type Alignment = 'left' | 'center' | 'right' | null;
|
||||
|
||||
export interface TableSnapshot {
|
||||
headers: Cell[]
|
||||
rows: Cell[][]
|
||||
alignments: Alignment[]
|
||||
}
|
||||
|
||||
export class TableState {
|
||||
public headers: Cell[] = [];
|
||||
public rows: Cell[][] = [];
|
||||
public alignments: Alignment[] = [];
|
||||
|
||||
public undoStack: string[] = [];
|
||||
public redoStack: string[] = [];
|
||||
|
||||
public get canUndo(): boolean {
|
||||
return this.undoStack.length > 1;
|
||||
}
|
||||
|
||||
public get canRedo(): boolean {
|
||||
return this.redoStack.length > 0;
|
||||
}
|
||||
|
||||
constructor(initialRows = 3, initialCols = 3) {
|
||||
this.reset(initialRows, initialCols);
|
||||
}
|
||||
|
||||
public reset(numRows: number, numCols: number) {
|
||||
this.headers = Array.from({ length: numCols }, () => ({ html: '' }));
|
||||
this.rows = Array.from({ length: numRows }, () =>
|
||||
Array.from({ length: numCols }, () => ({ html: '' })),
|
||||
);
|
||||
this.alignments = Array.from({ length: numCols }, () => null);
|
||||
this.clearHistory();
|
||||
this.undoStack.push(this.serialize());
|
||||
}
|
||||
|
||||
public clearHistory() {
|
||||
this.undoStack = [];
|
||||
this.redoStack = [];
|
||||
}
|
||||
|
||||
private serialize(): string {
|
||||
return JSON.stringify({
|
||||
headers: this.headers,
|
||||
rows: this.rows,
|
||||
alignments: this.alignments,
|
||||
});
|
||||
}
|
||||
|
||||
private deserialize(json: string) {
|
||||
const data = JSON.parse(json);
|
||||
this.headers = data.headers;
|
||||
this.rows = data.rows;
|
||||
this.alignments = data.alignments;
|
||||
}
|
||||
|
||||
public saveHistory() {
|
||||
this.undoStack.push(this.serialize());
|
||||
this.redoStack = [];
|
||||
}
|
||||
|
||||
public undo() {
|
||||
if (this.undoStack.length > 1) {
|
||||
const current = this.undoStack.pop();
|
||||
if (current) {
|
||||
this.redoStack.push(current);
|
||||
}
|
||||
const prev = this.undoStack[this.undoStack.length - 1];
|
||||
this.deserialize(prev);
|
||||
}
|
||||
}
|
||||
|
||||
public redo() {
|
||||
if (this.redoStack.length > 0) {
|
||||
const next = this.redoStack.pop();
|
||||
if (next) {
|
||||
this.undoStack.push(next);
|
||||
this.deserialize(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public setColumnAlignment(colIdx: number, align: Alignment) {
|
||||
if (colIdx >= 0 && colIdx < this.alignments.length) {
|
||||
this.alignments[colIdx] = align;
|
||||
this.saveHistory();
|
||||
}
|
||||
}
|
||||
|
||||
public insertRow(rowIdx: number, position: 'above' | 'below') {
|
||||
if (rowIdx >= 0 && rowIdx < this.rows.length) {
|
||||
const insertAt = position === 'above' ? rowIdx : rowIdx + 1;
|
||||
const numCols = this.headers.length;
|
||||
const newRow = Array.from({ length: numCols }, () => ({ html: '' }));
|
||||
this.rows.splice(insertAt, 0, newRow);
|
||||
this.saveHistory();
|
||||
}
|
||||
}
|
||||
|
||||
public deleteRow(rowIdx: number) {
|
||||
if (rowIdx >= 0 && rowIdx < this.rows.length) {
|
||||
if (this.rows.length > 1) {
|
||||
this.rows.splice(rowIdx, 1);
|
||||
this.saveHistory();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public insertColumn(colIdx: number, position: 'left' | 'right') {
|
||||
if (colIdx >= 0 && colIdx < this.headers.length) {
|
||||
const insertAt = position === 'left' ? colIdx : colIdx + 1;
|
||||
this.headers.splice(insertAt, 0, { html: '' });
|
||||
this.alignments.splice(insertAt, 0, null);
|
||||
for (const row of this.rows) {
|
||||
row.splice(insertAt, 0, { html: '' });
|
||||
}
|
||||
this.saveHistory();
|
||||
}
|
||||
}
|
||||
|
||||
public deleteColumn(colIdx: number) {
|
||||
if (colIdx >= 0 && colIdx < this.headers.length) {
|
||||
if (this.headers.length > 1) {
|
||||
this.headers.splice(colIdx, 1);
|
||||
this.alignments.splice(colIdx, 1);
|
||||
for (const row of this.rows) {
|
||||
row.splice(colIdx, 1);
|
||||
}
|
||||
this.saveHistory();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public transpose() {
|
||||
const oldRowsCount = this.rows.length;
|
||||
const oldColsCount = this.headers.length;
|
||||
|
||||
const newHeaders: Cell[] = [];
|
||||
const newRows: Cell[][] = [];
|
||||
const newAlignments: Alignment[] = Array.from({ length: oldRowsCount + 1 }, () => null);
|
||||
|
||||
newHeaders.push({ html: this.headers[0].html });
|
||||
for (let r = 0; r < oldRowsCount; r++) {
|
||||
newHeaders.push({ html: this.rows[r][0].html });
|
||||
}
|
||||
|
||||
for (let c = 1; c < oldColsCount; c++) {
|
||||
const row: Cell[] = [{ html: this.headers[c].html }];
|
||||
for (let r = 0; r < oldRowsCount; r++) {
|
||||
row.push({ html: this.rows[r][c].html });
|
||||
}
|
||||
newRows.push(row);
|
||||
}
|
||||
|
||||
if (newRows.length === 0) {
|
||||
newRows.push(Array.from({ length: newHeaders.length }, () => ({ html: '' })));
|
||||
}
|
||||
|
||||
this.headers = newHeaders;
|
||||
this.rows = newRows;
|
||||
this.alignments = newAlignments;
|
||||
this.saveHistory();
|
||||
}
|
||||
|
||||
// Helper to translate HTML cell format to Markdown syntax
|
||||
public static htmlToCellMarkdown(html: string): string {
|
||||
if (!html) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let text = html;
|
||||
text = text.replace(/<(b|strong)>(.*?)<\/\1>/gi, '**$2**');
|
||||
text = text.replace(/<(i|em)>(.*?)<\/\1>/gi, '*$2*');
|
||||
text = text.replace(/<code[^>]*>(.*?)<\/code>/gi, '`$1`');
|
||||
text = text.replace(/<br\s*\/?>/gi, '___BR___');
|
||||
text = text.replace(/<\/p><p>/gi, '___BR___');
|
||||
text = text.replace(/<\/div><div>/gi, '___BR___');
|
||||
text = text.replace(/<[^>]+>/g, '');
|
||||
text = text.replace(/___BR___/g, '<br>');
|
||||
text = text.replace(/\|/g, '\\|');
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
public toMarkdown(options: { compact: boolean }): string {
|
||||
const compact = options.compact;
|
||||
const mdHeaders = this.headers.map(h => TableState.htmlToCellMarkdown(h.html));
|
||||
const mdRows = this.rows.map(row => row.map(cell => TableState.htmlToCellMarkdown(cell.html)));
|
||||
|
||||
const colWidths = this.headers.map((_, colIdx) => {
|
||||
if (compact) {
|
||||
return 0;
|
||||
}
|
||||
let maxLen = mdHeaders[colIdx].length;
|
||||
for (const row of mdRows) {
|
||||
maxLen = Math.max(maxLen, row[colIdx].length);
|
||||
}
|
||||
return Math.max(maxLen, 3);
|
||||
});
|
||||
|
||||
const formatCell = (text: string, colIdx: number): string => {
|
||||
if (compact) {
|
||||
return text;
|
||||
}
|
||||
const width = colWidths[colIdx];
|
||||
return text.padEnd(width, ' ');
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
const headerLine = `|${mdHeaders.join('|')}|`;
|
||||
const separatorLine = `|${this.alignments.map((align) => {
|
||||
const dashCount = 3;
|
||||
if (align === 'left') {
|
||||
return `:${'-'.repeat(dashCount - 1)}`;
|
||||
}
|
||||
else if (align === 'center') {
|
||||
return `:${'-'.repeat(dashCount - 2)}:`;
|
||||
}
|
||||
else if (align === 'right') {
|
||||
return `${'-'.repeat(dashCount - 1)}:`;
|
||||
}
|
||||
else {
|
||||
return '-'.repeat(dashCount);
|
||||
}
|
||||
}).join('|')}|`;
|
||||
const bodyLines = mdRows.map((row) => {
|
||||
return `|${row.join('|')}|`;
|
||||
});
|
||||
return [headerLine, separatorLine, ...bodyLines].join('\n');
|
||||
}
|
||||
else {
|
||||
const headerLine = `| ${mdHeaders.map((h, i) => formatCell(h, i)).join(' | ')} |`;
|
||||
const separatorLine = `| ${this.alignments.map((align, i) => {
|
||||
const width = colWidths[i];
|
||||
const dashCount = width;
|
||||
|
||||
if (align === 'left') {
|
||||
return `:${'-'.repeat(dashCount - 1)}`;
|
||||
}
|
||||
else if (align === 'center') {
|
||||
return `:${'-'.repeat(dashCount - 2)}:`;
|
||||
}
|
||||
else if (align === 'right') {
|
||||
return `${'-'.repeat(dashCount - 1)}:`;
|
||||
}
|
||||
else {
|
||||
return '-'.repeat(dashCount);
|
||||
}
|
||||
}).join(' | ')} |`;
|
||||
const bodyLines = mdRows.map((row) => {
|
||||
return `| ${row.map((cell, i) => formatCell(cell, i)).join(' | ')} |`;
|
||||
});
|
||||
return [headerLine, separatorLine, ...bodyLines].join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
public parsePaste(html: string, text: string) {
|
||||
this.saveHistory();
|
||||
|
||||
if (html && html.includes('<table')) {
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const table = doc.querySelector('table');
|
||||
if (table) {
|
||||
const rows = Array.from(table.querySelectorAll('tr'));
|
||||
if (rows.length > 0) {
|
||||
const headerRow = rows[0];
|
||||
const headerCells = Array.from(headerRow.querySelectorAll('th, td'));
|
||||
const numCols = headerCells.length;
|
||||
|
||||
this.headers = headerCells.map(c => ({ html: c.innerHTML.trim() }));
|
||||
this.alignments = Array.from({ length: numCols }, () => null);
|
||||
|
||||
this.rows = rows.slice(1).map((tr) => {
|
||||
const cells = Array.from(tr.querySelectorAll('th, td'));
|
||||
return Array.from({ length: numCols }, (_, i) => ({
|
||||
html: cells[i] ? cells[i].innerHTML.trim() : '',
|
||||
}));
|
||||
});
|
||||
|
||||
if (this.rows.length === 0) {
|
||||
this.rows = [[...this.headers]];
|
||||
this.headers = Array.from({ length: numCols }, () => ({ html: 'Header' }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Failed to parse pasted HTML table:', e);
|
||||
}
|
||||
}
|
||||
|
||||
const content = text || '';
|
||||
if (content) {
|
||||
const lines = content.split(/\r?\n/).filter(line => line.trim().length > 0);
|
||||
if (lines.length > 0) {
|
||||
const firstLine = lines[0];
|
||||
const isTab = firstLine.includes('\t');
|
||||
const delimiter = isTab ? '\t' : ',';
|
||||
|
||||
const parseLine = (line: string) => {
|
||||
return line.split(delimiter).map((cell) => {
|
||||
let val = cell.trim();
|
||||
if (val.startsWith('"') && val.endsWith('"')) {
|
||||
val = val.substring(1, val.length - 1).replace(/""/g, '"');
|
||||
}
|
||||
return { html: val };
|
||||
});
|
||||
};
|
||||
|
||||
const firstRowCells = parseLine(lines[0]);
|
||||
const numCols = firstRowCells.length;
|
||||
|
||||
this.headers = firstRowCells;
|
||||
this.alignments = Array.from({ length: numCols }, () => null);
|
||||
this.rows = lines.slice(1).map((line) => {
|
||||
const cells = parseLine(line);
|
||||
return Array.from({ length: numCols }, (_, i) => ({
|
||||
html: cells[i] ? cells[i].html : '',
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
279
src/tools/table-to-markdown/table-to-markdown.vue
Normal file
279
src/tools/table-to-markdown/table-to-markdown.vue
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
ArrowBack,
|
||||
ArrowForwardUp,
|
||||
Exchange,
|
||||
Plus,
|
||||
Trash,
|
||||
} from '@vicons/tabler';
|
||||
import { TableState } from './table-state';
|
||||
import TableEditor from './table-editor.vue';
|
||||
import TextareaCopyable from '@/components/TextareaCopyable.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Initialize TableState
|
||||
const state = reactive(new TableState(3, 3));
|
||||
|
||||
// Compact Mode
|
||||
const compactMode = ref(false);
|
||||
|
||||
// Active Tab for Output: 'markdown' or 'preview'
|
||||
const activeTab = ref('markdown');
|
||||
|
||||
const tabOptions = computed(() => [
|
||||
{ label: t('tools.table-to-markdown.markdownOutput'), value: 'markdown' },
|
||||
{ label: t('tools.table-to-markdown.visualPreview'), value: 'preview' },
|
||||
]);
|
||||
|
||||
// Modal State
|
||||
const showNewTableModal = ref(false);
|
||||
const newTableRows = ref(3);
|
||||
const newTableCols = ref(3);
|
||||
|
||||
function openNewTableModal() {
|
||||
newTableRows.value = state.rows.length;
|
||||
newTableCols.value = state.headers.length;
|
||||
showNewTableModal.value = true;
|
||||
}
|
||||
|
||||
function confirmNewTable() {
|
||||
if (newTableRows.value >= 1 && newTableCols.value >= 1) {
|
||||
state.reset(newTableRows.value, newTableCols.value);
|
||||
}
|
||||
showNewTableModal.value = false;
|
||||
}
|
||||
|
||||
// Actions
|
||||
function transposeTable() {
|
||||
state.transpose();
|
||||
}
|
||||
|
||||
function clearTable() {
|
||||
// Clear all cell contents but preserve size and alignments
|
||||
state.saveHistory();
|
||||
state.headers.forEach((h) => {
|
||||
h.html = '';
|
||||
});
|
||||
state.rows.forEach((row) => {
|
||||
row.forEach((cell) => {
|
||||
cell.html = '';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function undoAction() {
|
||||
state.undo();
|
||||
}
|
||||
|
||||
function redoAction() {
|
||||
state.redo();
|
||||
}
|
||||
|
||||
// Computed Markdown output
|
||||
const markdownOutput = computed(() => {
|
||||
return state.toMarkdown({ compact: compactMode.value });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="table-to-markdown-tool">
|
||||
<!-- Toolbar -->
|
||||
<c-card class="toolbar-card" mb-4>
|
||||
<div class="toolbar-container">
|
||||
<n-space align="center" class="flex-wrap">
|
||||
<c-button @click="openNewTableModal">
|
||||
<n-icon :component="Plus" class="mr-1" />
|
||||
{{ t('tools.table-to-markdown.newTable') }}
|
||||
</c-button>
|
||||
|
||||
<c-button @click="transposeTable">
|
||||
<n-icon :component="Exchange" class="mr-1" />
|
||||
{{ t('tools.table-to-markdown.transpose') }}
|
||||
</c-button>
|
||||
|
||||
<c-button @click="clearTable">
|
||||
<n-icon :component="Trash" class="mr-1" />
|
||||
{{ t('tools.table-to-markdown.clear') }}
|
||||
</c-button>
|
||||
|
||||
<n-divider vertical />
|
||||
|
||||
<c-button :disabled="!state.canUndo" @click="undoAction">
|
||||
<n-icon :component="ArrowBack" class="mr-1" />
|
||||
{{ t('tools.table-to-markdown.undo') }}
|
||||
</c-button>
|
||||
|
||||
<c-button :disabled="!state.canRedo" @click="redoAction">
|
||||
<n-icon :component="ArrowForwardUp" class="mr-1" />
|
||||
{{ t('tools.table-to-markdown.redo') }}
|
||||
</c-button>
|
||||
</n-space>
|
||||
|
||||
<n-space align="center">
|
||||
<span class="text-sm font-medium">{{ t('tools.table-to-markdown.compactMode') }}</span>
|
||||
<n-switch v-model:value="compactMode" />
|
||||
</n-space>
|
||||
</div>
|
||||
</c-card>
|
||||
|
||||
<!-- Table Editor Workspace -->
|
||||
<c-card class="editor-container-card" mb-4>
|
||||
<div class="editor-section">
|
||||
<h3 class="section-title mb-2">
|
||||
Table Editor
|
||||
</h3>
|
||||
<TableEditor :state="state" />
|
||||
<div class="instructions">
|
||||
Tip: Right-click column (#) or row headers to add/delete/align columns and rows. Tab/Enter to navigate. You can paste spreadsheet tables or CSV data directly!
|
||||
</div>
|
||||
</div>
|
||||
</c-card>
|
||||
|
||||
<!-- Output Workspace -->
|
||||
<div class="output-container">
|
||||
<div class="output-header">
|
||||
<h3 class="section-title">
|
||||
Output
|
||||
</h3>
|
||||
<c-buttons-select v-model:value="activeTab" :options="tabOptions" />
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'markdown'" class="markdown-output-wrapper">
|
||||
<TextareaCopyable :value="markdownOutput" language="markdown" />
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'preview'" class="preview-output-wrapper">
|
||||
<c-card class="preview-card">
|
||||
<div class="preview-content">
|
||||
<c-markdown :markdown="markdownOutput" />
|
||||
</div>
|
||||
</c-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Table Presets Modal -->
|
||||
<c-modal v-model:open="showNewTableModal">
|
||||
<c-card :title="t('tools.table-to-markdown.newTableTitle')">
|
||||
<n-space vertical size="large">
|
||||
<n-form-item :label="t('tools.table-to-markdown.rows')">
|
||||
<n-input-number v-model:value="newTableRows" :min="1" :max="100" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('tools.table-to-markdown.columns')">
|
||||
<n-input-number v-model:value="newTableCols" :min="1" :max="100" />
|
||||
</n-form-item>
|
||||
<n-space justify="end" class="mt-4">
|
||||
<c-button @click="showNewTableModal = false">
|
||||
{{ t('tools.table-to-markdown.cancel') }}
|
||||
</c-button>
|
||||
<c-button type="primary" @click="confirmNewTable">
|
||||
{{ t('tools.table-to-markdown.create') }}
|
||||
</c-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</c-card>
|
||||
</c-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
.table-to-markdown-tool {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.toolbar-card {
|
||||
border-radius: 8px;
|
||||
background-color: var(--card-bg, #ffffff);
|
||||
}
|
||||
|
||||
.toolbar-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.editor-container-card {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color, #1f2225);
|
||||
}
|
||||
|
||||
.dark .section-title {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.instructions {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #8e8e93;
|
||||
}
|
||||
|
||||
.output-container {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.output-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--card-bg, #ffffff);
|
||||
border: 1px solid var(--border-color, #efeff5);
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
overflow-x: auto;
|
||||
font-family: inherit;
|
||||
|
||||
:deep(table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1em 0;
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid var(--border-color, #efeff5);
|
||||
padding: 10px 14px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: var(--header-bg, #fafafc);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.preview-card {
|
||||
border-color: #303033;
|
||||
background-color: #18181c;
|
||||
}
|
||||
.preview-content {
|
||||
:deep(table) {
|
||||
th,
|
||||
td {
|
||||
border-color: #303033;
|
||||
}
|
||||
th {
|
||||
background-color: #18181c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue