Merge remote-tracking branch 'origin/main_desktop_1' into main_laptop_1
This commit is contained in:
@@ -34,6 +34,8 @@ import type {
|
||||
CellStyle,
|
||||
Command,
|
||||
CommandEffect,
|
||||
CondFmtRule,
|
||||
DataValidationRule,
|
||||
Sheet,
|
||||
SheetCellAddress,
|
||||
Workbook,
|
||||
@@ -310,6 +312,15 @@ function insertDelete(
|
||||
const 범위 = shiftRange(d.범위);
|
||||
return 범위 ? [{ ...d, 범위 }] : [];
|
||||
});
|
||||
// 조건부서식 · 유효성 범위도 같이(104-2) — `remapCondFmtRanges`·`remapValidationRanges`(3단계) 와
|
||||
// 같은 규칙이지만 그 모듈을 안 끌어옴(기본 번들 크기 예산 `test_spreadsheet_size`) — 여기 그대로 적음.
|
||||
const remapRanges = <T extends { 범위: CellRange[] }>(rules: T[]): T[] =>
|
||||
rules.flatMap((rule) => {
|
||||
const 범위 = rule.범위.map(shiftRange).filter((r): r is CellRange => r !== null);
|
||||
return 범위.length ? [{ ...rule, 범위 }] : [];
|
||||
});
|
||||
if (s.조건부서식) s.조건부서식 = remapRanges<CondFmtRule>(s.조건부서식);
|
||||
if (s.유효성) s.유효성 = remapRanges<DataValidationRule>(s.유효성);
|
||||
|
||||
const axis = rows ? "행" : "열";
|
||||
const table = s[axis];
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { dialogKeys } from "./spreadsheet_actions";
|
||||
import { parseA1, toA1 } from "./spreadsheet_address";
|
||||
import type { Cell, CellRange, Sheet } from "./spreadsheet_types";
|
||||
import type { Cell, CellLink, CellRange, Command, Sheet } from "./spreadsheet_types";
|
||||
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
const TEXT = {
|
||||
@@ -51,6 +51,55 @@ function sheetUsedBounds(sheet: Sheet): { maxR: number; maxC: number } {
|
||||
return { maxR, maxC };
|
||||
}
|
||||
|
||||
/** 밀기 · 당기기 범위 안 알약 연결도 같이 옮김(104-2) — `axis` 범위(행이면 열 범위 · 열이면 행 범위) 안
|
||||
* 알약만 · `dir` +1 밀기(삽입) · -1 당기기(삭제, `amount` 폭만큼 지운 자리의 알약은 빠짐). 안 바뀌면 `undefined`
|
||||
* (되돌리기 짝에 빈 「시트통째」를 안 끼움). */
|
||||
function shiftLinks(
|
||||
sheet: Sheet,
|
||||
range: CellRange,
|
||||
axis: "col" | "row",
|
||||
dir: 1 | -1,
|
||||
amount: number,
|
||||
): CellLink[] | undefined {
|
||||
const list = sheet.연결;
|
||||
if (!list?.length) return undefined;
|
||||
const lo = axis === "col" ? range.r0 : range.c0;
|
||||
const hi = axis === "col" ? range.r1 : range.c1;
|
||||
const at = axis === "col" ? range.c0 : range.r0;
|
||||
let changed = false;
|
||||
const next = list.flatMap((link): CellLink[] => {
|
||||
const p = parseA1(link.칸);
|
||||
if (!p) return [link];
|
||||
const along = axis === "col" ? p.r : p.c;
|
||||
const cross = axis === "col" ? p.c : p.r;
|
||||
if (along < lo || along > hi || cross < at) return [link]; // 범위 밖 · 밀리는 자리 앞 — 그대로
|
||||
if (dir < 0 && cross < at + amount) return (changed = true), []; // 지운 자리 — 빠짐
|
||||
changed = true;
|
||||
const moved = cross + dir * amount;
|
||||
return [{ ...link, 칸: axis === "col" ? toA1(p.r, moved) : toA1(moved, p.c) }];
|
||||
});
|
||||
return changed ? next : undefined;
|
||||
}
|
||||
|
||||
/** 「칸」 명령(값만) 앞에 — 알약이 움직였으면 「시트통째」(연결만 바꿈)를 한 묶음으로(되돌리기 한 걸음) */
|
||||
function dispatchShift(
|
||||
ctx: SpreadsheetContext,
|
||||
sheet: Sheet,
|
||||
range: CellRange,
|
||||
axis: "col" | "row",
|
||||
dir: 1 | -1,
|
||||
amount: number,
|
||||
cells: Record<string, Cell | null>,
|
||||
): void {
|
||||
const cellCmd: Command = { 종류: "칸", 시트: ctx.selection.시트, 칸: cells };
|
||||
const linked = shiftLinks(sheet, range, axis, dir, amount);
|
||||
ctx.dispatch(
|
||||
linked
|
||||
? { 종류: "묶음", 명령: [{ 종류: "시트통째", 시트: { ...sheet, 연결: linked } }, cellCmd] }
|
||||
: cellCmd,
|
||||
);
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// 부분 범위 밀기 — 「칸」 명령으로 값 · 식 · 서식 번호만 옮김(위 머리 주석의 한계 참고)
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
@@ -66,7 +115,7 @@ export function insertShiftRight(ctx: SpreadsheetContext, range: CellRange): voi
|
||||
cells[toA1(r, c)] = src >= range.c0 ? (sheet.칸[toA1(r, src)] ?? null) : null;
|
||||
}
|
||||
}
|
||||
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
|
||||
dispatchShift(ctx, sheet, range, "col", 1, width, cells);
|
||||
}
|
||||
|
||||
export function insertShiftDown(ctx: SpreadsheetContext, range: CellRange): void {
|
||||
@@ -80,7 +129,7 @@ export function insertShiftDown(ctx: SpreadsheetContext, range: CellRange): void
|
||||
cells[toA1(r, c)] = src >= range.r0 ? (sheet.칸[toA1(src, c)] ?? null) : null;
|
||||
}
|
||||
}
|
||||
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
|
||||
dispatchShift(ctx, sheet, range, "row", 1, height, cells);
|
||||
}
|
||||
|
||||
export function deleteShiftLeft(ctx: SpreadsheetContext, range: CellRange): void {
|
||||
@@ -94,7 +143,7 @@ export function deleteShiftLeft(ctx: SpreadsheetContext, range: CellRange): void
|
||||
cells[toA1(r, c)] = src <= lastCol ? (sheet.칸[toA1(r, src)] ?? null) : null;
|
||||
}
|
||||
}
|
||||
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
|
||||
dispatchShift(ctx, sheet, range, "col", -1, width, cells);
|
||||
}
|
||||
|
||||
export function deleteShiftUp(ctx: SpreadsheetContext, range: CellRange): void {
|
||||
@@ -108,7 +157,7 @@ export function deleteShiftUp(ctx: SpreadsheetContext, range: CellRange): void {
|
||||
cells[toA1(r, c)] = src <= lastRow ? (sheet.칸[toA1(src, c)] ?? null) : null;
|
||||
}
|
||||
}
|
||||
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells });
|
||||
dispatchShift(ctx, sheet, range, "row", -1, height, cells);
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -250,24 +250,37 @@
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* 보낸 범위(도면 표) — 점선 테두리(채움 없음) + 왼위 이름표 */
|
||||
/* 보낸 범위(도면 표 · 104-1 · 104-3) — 점선 테두리(채움 없음) + 왼위 이름표 · 표마다 다른 색
|
||||
* (--ss-link-h · spreadsheet_link_color) · 같은 자리에 겹치면(104-3) 테두리는 안쪽으로 · 이름표는 옆으로 */
|
||||
.ss-link-range {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
box-sizing: border-box;
|
||||
border: 2px dashed var(--color-accent);
|
||||
border: 2px dashed hsl(var(--ss-link-h, 0) var(--ss-link-s) var(--ss-link-l));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ss-link-range.is-picked {
|
||||
border-style: solid;
|
||||
box-shadow: 0 0 0 1px hsl(var(--ss-link-h, 0) var(--ss-link-s) var(--ss-link-l));
|
||||
}
|
||||
|
||||
.ss-link-range__tag {
|
||||
position: absolute;
|
||||
left: -2px;
|
||||
bottom: 100%;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px 3px 0 0;
|
||||
background: var(--color-accent);
|
||||
background: hsl(var(--ss-link-h, 0) var(--ss-link-s) var(--ss-link-l));
|
||||
color: var(--color-canvas);
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
white-space: nowrap;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ss-link-range__tag:hover,
|
||||
.ss-link-range__tag:focus-visible {
|
||||
filter: brightness(1.15);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -21,11 +21,13 @@ import { askShare } from "./spreadsheet_link_ask";
|
||||
import { linkHues } from "./spreadsheet_link_color";
|
||||
import { clearLinksCommand, linkCommand, linksAt } from "./spreadsheet_link_set";
|
||||
import type { slotOf } from "./spreadsheet_editor";
|
||||
import { resolveName } from "./spreadsheet_names";
|
||||
import { shiftSpan } from "./spreadsheet_refshift";
|
||||
import { selectCell } from "./spreadsheet_selection";
|
||||
import { selectCell, selectSpan } from "./spreadsheet_selection";
|
||||
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
|
||||
import type {
|
||||
CellLink,
|
||||
CellRange,
|
||||
Command,
|
||||
CommandEffect,
|
||||
LinkMaterial,
|
||||
@@ -56,8 +58,17 @@ export interface LinkOptions {
|
||||
/** 입력 모드 — 호스트가 항목 목록을 믿을 수 있게 받았음(받기 실패 · 빈 목록 구분) · true 일 때만
|
||||
* 목록에 없는 연결 알약에 ⚠ + [×] */
|
||||
확정?: boolean;
|
||||
/** 다른 곳으로 보낸 칸 범위 표시(예: 도면 표) — 시트 id · 범위 · 이름표 · 마우스 올림 글 */
|
||||
표시범위?: { 시트: string; 범위: string; 이름: string; 설명?: string }[];
|
||||
/** 다른 곳으로 보낸 칸 범위 표시(예: 도면 표 · 104-3) — 시트 id · 범위(이름이 없거나 지워졌으면 이 범위) ·
|
||||
* `범위이름`(104-1) 있으면 이름정의로 지금 범위를 다시 품(행열 넣기 · 지우기를 따라감) · 이름표 · 마우스 올림 글 ·
|
||||
* `취소`(있으면 이름표 메뉴에 [보내기 취소]) — 연결만 끊고(true) 화면에서 뗌 · 실패(false)면 그대로 */
|
||||
표시범위?: {
|
||||
시트: string;
|
||||
범위: string;
|
||||
범위이름?: string;
|
||||
이름: string;
|
||||
설명?: string;
|
||||
취소?: () => Promise<boolean>;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface Links extends PartHandle {
|
||||
@@ -84,6 +95,10 @@ const TEXT = {
|
||||
dup: "이미 목록에 있는 자재",
|
||||
orphan: "일위대가에 없는 알약 — 연결을 풀면 [저장] 때 남음",
|
||||
orphanX: "연결 풀기",
|
||||
rangeTip: "누르면 이 범위를 고름",
|
||||
cancelSend: "보내기 취소",
|
||||
cancelTip: "연결만 끊음 — 도면 표는 남음",
|
||||
cancelFail: "연결을 못 끊음",
|
||||
};
|
||||
|
||||
type RowCol = Extract<Command, { 종류: "행넣기" | "행지우기" | "열넣기" | "열지우기" }>;
|
||||
@@ -158,6 +173,22 @@ export function mountLinks(
|
||||
let pick: HTMLElement | null = null;
|
||||
/** 마지막으로 강조를 판정한 칸(시트 id!칸) — 바뀔 때만 반짝 · 스크롤 */
|
||||
let lastAt = "";
|
||||
/** 누른 보낸범위 테두리(104-3) — 다음에 누르면 강조를 옮김 · 다시 그릴 때 비움(DOM 이 새로 생김) */
|
||||
let pickedFrame: HTMLElement | null = null;
|
||||
|
||||
/** 보낸 범위 마크의 지금 범위(104-1 · 104-3) — `범위이름` 있으면 그 이름이 지금 가리키는 범위로
|
||||
* (행열 넣기 · 지우기를 따라감) · 이름이 지워졌으면 null(서버가 다음 저장 때 연결을 끊음) ·
|
||||
* 이름이 없으면(옛 연결) 적힌 `범위` 그대로 */
|
||||
function markRange(
|
||||
mark: NonNullable<LinkOptions["표시범위"]>[number],
|
||||
): { 시트: string; g: CellRange } | null {
|
||||
if (mark.범위이름) {
|
||||
const found = resolveName(ctx.book, mark.시트, mark.범위이름);
|
||||
return found && { 시트: found.시트, g: found.범위 };
|
||||
}
|
||||
const g = parseRange(mark.범위);
|
||||
return g && { 시트: mark.시트, g };
|
||||
}
|
||||
|
||||
/** 키 → 연결(시트 · 줄) */
|
||||
function found(): Map<string, { sheet: Sheet; link: CellLink }> {
|
||||
@@ -386,6 +417,7 @@ export function mountLinks(
|
||||
function placeDots(): void {
|
||||
for (const d of dots) d.remove();
|
||||
dots = [];
|
||||
pickedFrame = null; // 테두리 DOM 이 새로 생김 — 눌린 상태는 다시 눌러야 함
|
||||
const hues = linkHues(found().keys());
|
||||
const listed = new Set(items.map((i) => i.키));
|
||||
const sure = opts?.모드 === "입력" && opts.확정 === true;
|
||||
@@ -407,18 +439,31 @@ export function mountLinks(
|
||||
cellSlots.push({ list, layer, box });
|
||||
}
|
||||
const rangeSlots: Array<{
|
||||
mark: { 시트: string; 범위: string; 이름: string; 설명?: string };
|
||||
mark: NonNullable<LinkOptions["표시범위"]>[number];
|
||||
g: CellRange;
|
||||
layer: HTMLElement;
|
||||
box: ReturnType<typeof slot>["box"];
|
||||
end: ReturnType<typeof slot>["box"];
|
||||
/** 같은 왼위 자리를 먼저 쓴 수(104-3 — 겹치면 몇째인지) */
|
||||
dup: number;
|
||||
hue: number;
|
||||
}> = [];
|
||||
for (const mark of opts?.표시범위 ?? []) {
|
||||
const g = mark.시트 === ctx.sheet().id ? parseRange(mark.범위) : null;
|
||||
if (!g) continue;
|
||||
const marks = opts?.표시범위 ?? [];
|
||||
// 색 = 범위 이름(104-1) 없으면 시트!범위 — 표마다 다르게(104-3 · 알약 색과는 다른 자리)
|
||||
const rangeHues = linkHues(marks.map((m) => m.범위이름 ?? `${m.시트}!${m.범위}`));
|
||||
const dupCount = new Map<string, number>(); // 왼위 자리 키 → 지금까지 그 자리에 둔 수
|
||||
for (const mark of marks) {
|
||||
const found = markRange(mark);
|
||||
if (!found || found.시트 !== ctx.sheet().id) continue;
|
||||
const { g } = found;
|
||||
const { layer, box } = slot(ctx, g.r0, g.c0);
|
||||
const end = slot(ctx, g.r1, g.c1).box;
|
||||
if (!box.w || !box.h) continue;
|
||||
rangeSlots.push({ mark, layer, box, end });
|
||||
const key = `${g.r0},${g.c0}`;
|
||||
const dup = dupCount.get(key) ?? 0;
|
||||
dupCount.set(key, dup + 1);
|
||||
const hue = rangeHues.get(mark.범위이름 ?? `${mark.시트}!${mark.범위}`) ?? 0;
|
||||
rangeSlots.push({ mark, g, layer, box, end, dup, hue });
|
||||
}
|
||||
|
||||
// 2) 쓰기 — 모은 자리에 한 번에 그림
|
||||
@@ -441,14 +486,49 @@ export function mountLinks(
|
||||
layer.append(...marks);
|
||||
dots.push(...marks);
|
||||
}
|
||||
// 보낸 범위(도면 표 등) — 채움 없는 점선 테두리 + 왼위 이름표(알약 색 채움과 안 겹침)
|
||||
for (const { mark, layer, box, end } of rangeSlots) {
|
||||
// 보낸 범위(도면 표 등 · 104-1 · 104-3) — 채움 없는 점선 테두리 + 왼위 이름표 · 표마다 다른 색 ·
|
||||
// 같은 자리가 겹치면(dup) 테두리는 안쪽으로 · 이름표는 옆으로 비킴 · 이름표만 누를 수 있음
|
||||
for (const { mark, g, layer, box, end, dup, hue } of rangeSlots) {
|
||||
const w = Math.max(box.w, end.x + end.w - box.x);
|
||||
const h = Math.max(box.h, end.y + end.h - box.y);
|
||||
const inset = dup * 3;
|
||||
const tag = el("span", {
|
||||
className: "ss-link-range__tag",
|
||||
text: mark.이름,
|
||||
attrs: { title: mark.설명 ?? TEXT.rangeTip, role: "button", tabindex: "0" },
|
||||
});
|
||||
tag.style.left = `${dup * 44}px`;
|
||||
const frame = el("div", {
|
||||
className: "ss-link-range",
|
||||
attrs: { "data-range": mark.범위 },
|
||||
children: [el("span", { className: "ss-link-range__tag", text: mark.이름 })],
|
||||
children: [tag],
|
||||
});
|
||||
frame.style.cssText = `left:${box.x}px;top:${box.y}px;width:${Math.max(box.w, end.x + end.w - box.x)}px;height:${Math.max(box.h, end.y + end.h - box.y)}px`;
|
||||
frame.style.cssText = `left:${box.x + inset}px;top:${box.y + inset}px;width:${Math.max(w - inset * 2, 0)}px;height:${Math.max(h - inset * 2, 0)}px`;
|
||||
frame.style.setProperty("--ss-link-h", String(hue));
|
||||
tag.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
pickedFrame?.classList.remove("is-picked");
|
||||
frame.classList.add("is-picked");
|
||||
pickedFrame = frame;
|
||||
ctx.select(selectSpan(ctx.sheet(), { r: g.r0, c: g.c0 }, { r: g.r1, c: g.c1 }), true);
|
||||
});
|
||||
if (mark.취소 && !ctx.readOnly) {
|
||||
tag.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
const o = ctx.root.getBoundingClientRect();
|
||||
menu.open(e.clientX - o.left, e.clientY - o.top, [
|
||||
[
|
||||
TEXT.cancelSend,
|
||||
() =>
|
||||
void mark.취소!().then((ok) => {
|
||||
if (!ok) return showToast(TEXT.cancelFail, "error");
|
||||
if (opts) opts.표시범위 = opts.표시범위?.filter((m) => m !== mark);
|
||||
placeDots();
|
||||
}),
|
||||
] as [string, () => void],
|
||||
]);
|
||||
});
|
||||
}
|
||||
layer.append(frame);
|
||||
dots.push(frame);
|
||||
}
|
||||
@@ -464,8 +544,9 @@ export function mountLinks(
|
||||
: [];
|
||||
if (hit && hit.r >= 0 && hit.c >= 0)
|
||||
for (const m of opts?.표시범위 ?? []) {
|
||||
const g = m.시트 === ctx.sheet().id ? parseRange(m.범위) : null;
|
||||
if (g && inRange(g, hit.r, hit.c)) names.push(m.설명 ?? m.이름);
|
||||
const found = markRange(m);
|
||||
if (found && found.시트 === ctx.sheet().id && inRange(found.g, hit.r, hit.c))
|
||||
names.push(m.설명 ?? m.이름);
|
||||
}
|
||||
if (names.length) ctx.grid.root.title = names.join(" · ");
|
||||
else if (tipped) ctx.grid.root.removeAttribute("title");
|
||||
|
||||
Reference in New Issue
Block a user