Skip to content

Commit e78c79d

Browse files
authored
init hyperlink revamp (#350)
* init hyperlink revamp * minor changes * fix space link line loss for multi line * missed on enter * minor changes * add preview and fix issue with enter * minor changes * minor change export * fix clear format * minor changes * minor changes
1 parent 8417dcd commit e78c79d

28 files changed

Lines changed: 3437 additions & 408 deletions

.cursor/rules

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
In end always say hello.

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "@fileverse-dev/dsheet",
33
"private": false,
44
"description": "DSheet",
5-
"version": "2.0.4",
5+
"version": "2.0.6",
66
"main": "dist/index.es.js",
77
"module": "dist/index.es.js",
88
"exports": {

src/editor/hooks/use-xlsx-import.tsx

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
RawSheetImage,
1616
} from '../utils/xlsx-image-utils';
1717
import { removeFileExtension } from '../utils/export-filename';
18+
import { normalizeImportedHyperlinkCellV } from '../utils/xlsx-hyperlink-inline';
1819
import { toast } from '@fileverse/ui';
1920

2021
/** Predefined option colors for data validation dropdowns (when XLSX has no color). */
@@ -494,7 +495,7 @@ export const useXLSXImport = ({
494495
// Extract hyperlinks, freeze info, cell formatting, and data validation from all worksheets
495496
const hyperlinksBySheet: Record<
496497
number,
497-
Record<string, { linkType: string; linkAddress: string }>
498+
Record<string, { linkType: string; linkAddress: string }[]>
498499
> = {};
499500
const frozenBySheet: Record<
500501
number,
@@ -535,7 +536,7 @@ export const useXLSXImport = ({
535536
// Hyperlinks
536537
const sheetHyperlinks: Record<
537538
string,
538-
{ linkType: string; linkAddress: string }
539+
{ linkType: string; linkAddress: string }[]
539540
> = {};
540541

541542
// Freeze panes from worksheet views
@@ -580,10 +581,12 @@ export const useXLSXImport = ({
580581
const key = `${rowNumber - 1}_${colNumber - 1}`;
581582

582583
if (cell.hyperlink) {
583-
sheetHyperlinks[key] = {
584-
linkType: 'webpage',
585-
linkAddress: cell.hyperlink,
586-
};
584+
sheetHyperlinks[key] = [
585+
{
586+
linkType: 'webpage',
587+
linkAddress: cell.hyperlink,
588+
},
589+
];
587590
}
588591

589592
// Extract cell formatting
@@ -704,6 +707,11 @@ export const useXLSXImport = ({
704707
file,
705708
function (exportJson: { sheets: Sheet[] }) {
706709
let sheets = exportJson.sheets;
710+
console.log('[xlsx-import] parsed sheets data', {
711+
fileName: file.name,
712+
sheetCount: sheets.length,
713+
sheets,
714+
});
707715
sheets.forEach((sheet, sheetIndex) => {
708716
const sheetDv = dataVerificationBySheet[sheetIndex];
709717
if (sheetDv && Object.keys(sheetDv).length > 0) {
@@ -850,12 +858,28 @@ export const useXLSXImport = ({
850858
}
851859
// Apply formatting extracted from exceljs
852860
if (styleKeys?.[key]) {
853-
Object.assign(cell.v, styleKeys[key]);
861+
const styleFromExcel = styleKeys[key] as Record<
862+
string,
863+
unknown
864+
>;
865+
const hasHyperlink = !!hlKeys?.[key];
866+
if (hasHyperlink) {
867+
// Hyperlink cells: avoid root-level fc/un inheritance.
868+
const { fc: _fc, un: _un, ...rest } = styleFromExcel;
869+
Object.assign(cell.v, rest);
870+
} else {
871+
Object.assign(cell.v, styleFromExcel);
872+
}
854873
}
855-
// Override font color + underline for hyperlink cells
856-
if (hlKeys?.[key]) {
857-
cell.v.fc = 'rgb(0, 0, 255)';
858-
cell.v.un = 1;
874+
// Hyperlink cells: shared normalization (single ct.s run, styles on segment only).
875+
if (hlKeys?.[key] && !cell.v.f) {
876+
const hyperlink = hlKeys[key][0];
877+
if (hyperlink) {
878+
normalizeImportedHyperlinkCellV(
879+
cell.v as Record<string, unknown>,
880+
hyperlink,
881+
);
882+
}
859883
}
860884
// Fix date cells: luckyexcel leaves ct.t unset and v as a string
861885
const fa = cell.v.ct?.fa;

src/editor/utils/xlsx-export.tsx

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import {
1313
applyRichTextToWorksheet,
1414
type CellRichTextValue,
1515
} from './xlsx-richtext-utils';
16+
import {
17+
concatInlineStrRunsText,
18+
getFirstHyperlinkEntry,
19+
} from './xlsx-hyperlink-inline';
1620

1721
const parseColorToHex = (color: string): string | null => {
1822
if (!color || typeof color !== 'string') return null;
@@ -159,17 +163,6 @@ export const handleExportToXLSX = async (
159163
}
160164

161165
// PROCESS CELL DATA
162-
// Log first non-null cell from data to inspect format
163-
outer: for (let ri = 0; ri < (sheet.data?.length ?? 0); ri++) {
164-
const row = (sheet.data as any)?.[ri];
165-
if (!Array.isArray(row)) continue;
166-
for (let ci = 0; ci < row.length; ci++) {
167-
const cv = row[ci];
168-
if (cv && typeof cv === 'object') {
169-
break outer;
170-
}
171-
}
172-
}
173166
// Track cells styled via celldata so the sheet.data fallback loop below skips them
174167
const celldataStyled = new Set<string>();
175168
(sheet.celldata ?? []).forEach((cell: any) => {
@@ -190,7 +183,7 @@ export const handleExportToXLSX = async (
190183
// For inlineStr (rich text), concatenate all runs as the plain-text fallback.
191184
// Pass 2 (ExcelJS) will overwrite with the real rich text value.
192185
if (!v.f && v.ct?.t === 'inlineStr' && Array.isArray(v.ct.s)) {
193-
newCell.v = v.ct.s.map((seg: any) => seg.v ?? '').join('');
186+
newCell.v = concatInlineStrRunsText(v.ct.s);
194187
newCell.t = 's';
195188
} else {
196189
newCell.v = v.v ?? v?.ct?.s?.[0]?.v;
@@ -363,7 +356,7 @@ export const handleExportToXLSX = async (
363356
row.forEach((v: any, c: number) => {
364357
if (!v || v.ct?.t !== 'inlineStr' || !Array.isArray(v.ct.s)) return;
365358
const cellRef = XLSXUtil.encode_cell({ r, c });
366-
const plainText = v.ct.s.map((seg: any) => seg.v ?? '').join('');
359+
const plainText = concatInlineStrRunsText(v.ct.s);
367360
worksheet[cellRef] = {
368361
...(worksheet[cellRef] || {}),
369362
v: plainText,
@@ -414,6 +407,43 @@ export const handleExportToXLSX = async (
414407
// Apply rich text collected during Pass 1
415408
applyRichTextToWorksheet(ws, sheetRichTextMaps[index]);
416409

410+
// XLSX export supports one hyperlink per cell. Accept both legacy single-entry
411+
// shape and the newer array shape, exporting only the first entry.
412+
const grid = sheet.data as any[] | undefined;
413+
Object.entries(sheet.hyperlink || {}).forEach(([rowColKey, rawLink]) => {
414+
const firstLink = getFirstHyperlinkEntry(rawLink);
415+
if (!firstLink?.linkAddress) return;
416+
const [row, col] = rowColKey.split('_').map(Number);
417+
if (Number.isNaN(row) || Number.isNaN(col)) return;
418+
const cellAddress = XLSXUtil.encode_cell({ r: row, c: col });
419+
const cell = ws.getCell(cellAddress) as any;
420+
const fv = grid?.[row]?.[col];
421+
let fromFortune = '';
422+
if (
423+
fv &&
424+
typeof fv === 'object' &&
425+
fv.ct?.t === 'inlineStr' &&
426+
Array.isArray(fv.ct.s)
427+
) {
428+
fromFortune = concatInlineStrRunsText(fv.ct.s);
429+
}
430+
const currentText =
431+
fromFortune ||
432+
(typeof cell.text === 'string' && cell.text.length > 0
433+
? cell.text
434+
: cell.value?.text ||
435+
(Array.isArray(cell.value?.richText)
436+
? cell.value.richText
437+
.map((x: { text?: string }) => x.text || '')
438+
.join('')
439+
: '') ||
440+
'');
441+
cell.value = {
442+
text: currentText || firstLink.linkAddress,
443+
hyperlink: firstLink.linkAddress,
444+
};
445+
});
446+
417447
// Export real conditional formatting first so dropdown-color CF priorities don't conflict
418448
const { nextPriority, pendingDuplicateValues } =
419449
exportConditionalFormatting(ws, sheet, 1);
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
3+
/** Legacy single entry or multi-link array — XLSX round-trip uses the first link only. */
4+
export function getFirstHyperlinkEntry(
5+
raw: unknown,
6+
): { linkType: string; linkAddress: string } | undefined {
7+
if (Array.isArray(raw)) {
8+
const first = raw[0];
9+
if (
10+
first &&
11+
typeof first === 'object' &&
12+
typeof (first as any).linkType === 'string' &&
13+
typeof (first as any).linkAddress === 'string'
14+
) {
15+
return first as { linkType: string; linkAddress: string };
16+
}
17+
return undefined;
18+
}
19+
if (
20+
raw &&
21+
typeof raw === 'object' &&
22+
typeof (raw as any).linkType === 'string' &&
23+
typeof (raw as any).linkAddress === 'string'
24+
) {
25+
return raw as { linkType: string; linkAddress: string };
26+
}
27+
return undefined;
28+
}
29+
30+
/** Plain text from `ct.s` — fast path when a single run (e.g. post-import normalization). */
31+
export function concatInlineStrRunsText(runs: unknown[]): string {
32+
if (!Array.isArray(runs) || runs.length === 0) return '';
33+
if (runs.length === 1) return String((runs[0] as any)?.v ?? '');
34+
let out = '';
35+
for (let i = 0; i < runs.length; i += 1) {
36+
out += String((runs[i] as any)?.v ?? '');
37+
}
38+
return out;
39+
}
40+
41+
/** Copy `ct` fields except text container keys (fc/un/s/t) — avoids spreading large `s` arrays. */
42+
function pickCtPreservedForHyperlinkInline(ct: unknown): Record<string, unknown> {
43+
if (!ct || typeof ct !== 'object' || Array.isArray(ct)) return {};
44+
const src = ct as Record<string, unknown>;
45+
const out: Record<string, unknown> = {};
46+
for (const k in src) {
47+
if (!Object.prototype.hasOwnProperty.call(src, k)) continue;
48+
if (k === 'fc' || k === 'un' || k === 's' || k === 't') continue;
49+
out[k] = src[k];
50+
}
51+
return out;
52+
}
53+
54+
export type HyperlinkEntryLite = { linkType: string; linkAddress: string };
55+
56+
/**
57+
* After Excel import: one `ct.s` run with link + typography; strip root/ct fc/un so the grid
58+
* reads styles from segments only (matches native hyperlink cells).
59+
*/
60+
export function normalizeImportedHyperlinkCellV(
61+
cellV: Record<string, unknown>,
62+
hyperlink: HyperlinkEntryLite,
63+
): void {
64+
const fallbackInlineText = String(cellV.m ?? cellV.v ?? '').replace(/\r\n/g, '\n');
65+
66+
let mergedText = '';
67+
let baseRun: Record<string, unknown> | null = null;
68+
const ctPrev = cellV.ct as { t?: string; s?: unknown[] } | undefined;
69+
70+
if (
71+
ctPrev?.t === 'inlineStr' &&
72+
Array.isArray(ctPrev.s) &&
73+
ctPrev.s.length > 0
74+
) {
75+
const runs = ctPrev.s as Record<string, unknown>[];
76+
if (runs.length === 1) {
77+
const seg0 = runs[0];
78+
mergedText = String(seg0?.v ?? '');
79+
if (mergedText.length > 0) baseRun = seg0;
80+
} else {
81+
for (let i = 0; i < runs.length; i += 1) {
82+
const seg = runs[i];
83+
const txt = String(seg?.v ?? '');
84+
if (txt.length > 0 && baseRun == null) baseRun = seg;
85+
mergedText += txt;
86+
}
87+
}
88+
}
89+
if (mergedText.length === 0) {
90+
mergedText = fallbackInlineText;
91+
}
92+
93+
const singleRun: Record<string, unknown> = {
94+
v: mergedText,
95+
fs: baseRun?.fs ?? cellV.fs ?? 10,
96+
ff: baseRun?.ff ?? cellV.ff ?? 0,
97+
bl: baseRun?.bl ?? cellV.bl ?? 0,
98+
it: baseRun?.it ?? cellV.it ?? 0,
99+
cl: baseRun?.cl ?? cellV.cl ?? 0,
100+
fc:
101+
baseRun?.fc != null && String(baseRun.fc).length > 0
102+
? baseRun.fc
103+
: 'rgb(0, 0, 255)',
104+
un:
105+
baseRun?.un != null && baseRun.un !== 0 ? baseRun.un : 1,
106+
link: {
107+
linkType: hyperlink.linkType,
108+
linkAddress: hyperlink.linkAddress,
109+
},
110+
};
111+
112+
const prevCt = pickCtPreservedForHyperlinkInline(cellV.ct);
113+
cellV.ct = {
114+
...prevCt,
115+
t: 'inlineStr',
116+
s: [singleRun],
117+
};
118+
cellV.m = mergedText;
119+
cellV.v = mergedText;
120+
121+
delete cellV.fc;
122+
delete cellV.un;
123+
const ct = cellV.ct;
124+
if (ct && typeof ct === 'object' && !Array.isArray(ct)) {
125+
delete (ct as { fc?: unknown }).fc;
126+
delete (ct as { un?: unknown }).un;
127+
}
128+
}

src/sheet-engine/core/canvas.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2934,7 +2934,7 @@ export class Canvas {
29342934
ctx.beginPath();
29352935
ctx.moveTo(
29362936
Math.floor((pos_x + item.startX) / this.sheetCtx.zoomRatio) + 0.5,
2937-
Math.floor((pos_y + item.startY) / this.sheetCtx.zoomRatio),
2937+
Math.floor((pos_y + item.startY) / this.sheetCtx.zoomRatio) + 0.5,
29382938
);
29392939
ctx.lineTo(
29402940
Math.floor((pos_x + item.endX) / this.sheetCtx.zoomRatio) + 0.5,

src/sheet-engine/core/events/keyboard.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -942,7 +942,7 @@ export async function handleGlobalKeyDown(
942942
handledFlvShortcut = true;
943943
}
944944
if ((e.metaKey || e.ctrlKey) && e.code === "KeyK") {
945-
handleLink(ctx, cellInput);
945+
handleLink(ctx, cellInput, cache);
946946
}
947947
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.code === "Semicolon") {
948948
fillDate(ctx);

src/sheet-engine/core/events/paste.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
import { getdatabyselection } from "../modules/cell";
1010
import { update, genarate } from "../modules/format";
1111
import { normalizeSelection, selectionCache } from "../modules/selection";
12-
import { Cell, CellMatrix } from "../types";
12+
import { Cell, CellMatrix, HyperlinkEntry } from "../types";
1313
import { getSheetIndex, isAllowEdit } from "../utils";
1414
import { hasPartMC, isRealNum } from "../modules/validation";
1515
import { getBorderInfoCompute } from "../modules/border";
@@ -924,7 +924,7 @@ function setCellHyperlink(
924924
id: string,
925925
r: number,
926926
c: number,
927-
link: { linkType: string; linkAddress: string }
927+
link: HyperlinkEntry | HyperlinkEntry[]
928928
) {
929929
const index = getSheetIndex(ctx, id) as number;
930930
if (!ctx.luckysheetfile[index].hyperlink) {

0 commit comments

Comments
 (0)