-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathstate.js
More file actions
298 lines (266 loc) · 14.6 KB
/
state.js
File metadata and controls
298 lines (266 loc) · 14.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
// state.js
export const CONSTANTS = {
ITEM_TYPE: { FOLDER: 'folder', NOTE: 'note' },
MODAL_TYPE: { PROMPT: 'prompt', CONFIRM: 'confirm', ALERT: 'alert' },
TOAST_TYPE: { SUCCESS: 'success', ERROR: 'error' },
LS_KEY: 'newTabNoteLastSession_v11.0',
LS_KEY_IMPORT_IN_PROGRESS: 'mothnote_import_in_progress_v1',
// [버그 수정] 데이터 무결성을 파괴할 수 있는 위험한 전체 상태 비상 백업 키를 제거합니다.
// 대신, 안전한 복원 절차를 위해 '변경사항'만 기록하는 새로운 키를 도입합니다.
LS_KEY_EMERGENCY_CHANGES_BACKUP: 'mothnote_emergency_changes_backup_v1',
// 설정 관련 상수
LS_KEY_SETTINGS: 'newTabNoteSettings_v2',
DEFAULT_SETTINGS: {
layout: { col1: 10, col2: 10 },
editor: { fontFamily: `-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Helvetica Neue", sans-serif`, fontSize: 17 },
weather: { lat: 37.5660, lon: 126.9784 },
zenMode: { maxWidth: 850 }
},
// 가상 폴더 및 UI 관련 상수
VIRTUAL_FOLDERS: {
ALL: { id: 'all-notes-virtual-id', name: '모든 노트', displayName: '📚 모든 노트', icon: '📚', canAddNote: false, getNotes: (state) => Array.from(state.noteMap.values()).map(entry => entry.note) },
// [수정] slice()를 제거하여 최근 10개가 아닌 전체 노트를 최신순으로 반환하도록 변경
RECENT: { id: 'recent-notes-virtual-id', name: '최근 노트', displayName: '🕒 최근 노트', icon: '🕒', canAddNote: false, isSortable: false, getNotes: (state) => Array.from(state.noteMap.values()).map(entry => entry.note).sort((a,b) => b.updatedAt - a.updatedAt) },
FAVORITES: { id: 'favorites-virtual-id', name: '즐겨찾기', displayName: '⭐ 즐겨찾기', icon: '⭐', canAddNote: false, isSortable: true, getNotes: (state) => Array.from(state.noteMap.values()).map(entry => entry.note).filter(note => state.favorites.has(note.id)) },
TRASH: { id: 'trash-virtual-id', name: '휴지통', displayName: '🗑️ 휴지통', icon: '🗑️', canAddNote: false, isSortable: false, getNotes: (state) => state.trash }
},
CLASSES: {
DRAGGING: 'dragging',
DROP_TARGET: 'drop-target',
PINNED: 'pinned',
ACTIVE: 'active',
READONLY: 'readonly'
},
MODAL_TITLES: {
NEW_FOLDER: '📁 새 폴더 만들기',
PERM_DELETE: '💥 영구 삭제',
EMPTY_TRASH: '🗑️ 휴지통 비우기',
IMPORT_DATA: '⚠️ 데이터 가져오기',
SHORTCUT_GUIDE: '⌨️ 단축키 안내',
UNSAVED_CHANGES: '📝 저장되지 않은 변경사항',
WEATHER_LOCATION: '🌦️ 날씨 지역 설정'
},
ID_PREFIX: {
FOLDER: 'folder-',
NOTE: 'note-'
},
DEBOUNCE_DELAY: {
KEY_NAV: 200,
SEARCH: 300,
SAVE: 500,
WEATHER_SEARCH: 500
},
EDITOR: {
DOM_IDS: {
container: 'editor-container',
titleInput: 'note-title-input',
contentTextArea: 'note-content-textarea',
contentView: 'note-content-view',
footer: 'editor-footer',
updatedDate: 'updated-date',
createdDate: 'created-date',
wordCount: 'word-count',
charCount: 'char-count',
lineCount: 'line-count',
saveStatus: 'save-status-indicator',
placeholderIcon: 'placeholder-icon'
}
},
DASHBOARD: {
WEATHER_CACHE_KEY: 'weather_cache_v1',
DOM_IDS: {
digitalClock: 'digital-clock',
analogClockCanvas: 'analog-clock',
weatherContainer: 'weather-container',
calendarGrid: 'calendar-grid',
calendarMonthYear: 'calendar-month-year',
prevMonthBtn: 'prev-month-btn',
nextMonthBtn: 'next-month-btn',
},
WMO_MAP: {
0: { icon: "☀️", text: "맑음" }, 1: { icon: "🌤️", text: "대체로 맑음" }, 2: { icon: "🌥️", text: "구름 조금" }, 3: { icon: "☁️", text: "흐림" }, 45: { icon: "🌫️", text: "안개" }, 48: { icon: "🌫️", text: "짙은 안개" }, 51: { icon: "🌦️", text: "가랑비" }, 53: { icon: "🌦️", text: "가랑비" }, 55: { icon: "🌦️", text: "강한 가랑비" }, 56: { icon: "🥶💧", text: "어는 가랑비" }, 57: { icon: "🥶💧", text: "강한 어는 가랑비" }, 61: { icon: "🌧️", text: "비" }, 63: { icon: "🌧️", text: "비" }, 65: { icon: "🌧️", text: "강한 비" }, 66: { icon: "🥶🌧️", text: "어는 비" }, 67: { icon: "🥶🌧️", text: "강한 어는 비" }, 71: { icon: "❄️", text: "눈" }, 73: { icon: "❄️", text: "눈" }, 75: { icon: "❄️", text: "강한 눈" }, 77: { icon: "🌨️", text: "싸락눈" }, 80: { icon: "🌧️", text: "소나기" }, 81: { icon: "🌧️", text: "소나기" }, 82: { icon: "⛈️", text: "강한 소나기" }, 85: { icon: "🌨️", text: "소낙눈" }, 86: { icon: "🌨️", "text": "강한 소낙눈" }, 95: { icon: "⛈️", text: "뇌우" }, 96: { icon: "⛈️🧊", text: "뇌우 (우박 동반)" }, 99: { icon: "⛈️🧊", text: "강한 뇌우 (우박 동반)" },
}
},
AUTO_TITLE_LENGTH: 100,
AUTO_TITLE_LENGTH_KOR: 50,
RECENT_NOTES_COUNT: 10,
TOAST_DURATION: 4000,
PLACEHOLDER_EMOJIS: ['🦋', '💡', '✨', '✍️', '🌱', '🎨', '🧠', '🌟', '☕', '📖', '📝', '🧭', '🔭', '🗺️', '🤔', '🌿', '🌻', '🍃', '🌈', '🦉', '🪐', '🌌', '🧘', '🍵', '🪁', '🎈', '🚀', '💎', '🎯', '🔑', '🖋️', '✏️', '🖌️', '🎶', '💭', '🌳', '🌊', '🐚', '🌕', '🌙', '🏔️', '📚', '🔎', '📎', '🔗', '🧩', '🕯️', '🔮', '⏳', '♾️'],
MESSAGES: {
SUCCESS: {
NOTE_PINNED: '📍 노트를 고정했습니다.', NOTE_UNPINNED: '📌 노트 고정을 해제했습니다.',
NOTE_FAVORITED: '⭐ 노트를 즐겨찾기에 추가했습니다.', NOTE_UNFAVORITED: '⚝ 즐겨찾기에서 노트를 제거했습니다.',
FOLDER_MOVED_TO_TRASH: name => `🗑️ '${name}' 폴더를 휴지통으로 이동했습니다.`,
NOTE_MOVED_TO_TRASH: name => `🗑️ '${name}' 노트를 휴지통으로 이동했습니다.`,
ITEM_RESTORED_FOLDER: name => `♻️ 📁 '${name}' 폴더와 노트를 복원했습니다.`,
ITEM_RESTORED_NOTE: name => `♻️ 📝 '${name}' 노트를 복원했습니다.`,
PERM_DELETE_ITEM_SUCCESS: '💥 항목을 영구적으로 삭제했습니다.', EMPTY_TRASH_SUCCESS: '🗑️ 휴지통을 비웠습니다.',
NOTE_MOVED_SUCCESS: (noteTitle, folderName) => `✅ '${noteTitle}' 노트를 '${folderName}' 폴더로 이동했습니다.`,
EXPORT_SUCCESS: '📤 데이터 내보내기 성공!', IMPORT_SUCCESS: '📥 데이터를 성공적으로 가져왔습니다!',
SETTINGS_SAVED: '⚙️ 설정이 저장되었습니다.', SETTINGS_RESET: '⚙️ 설정이 기본값으로 복원되었습니다.',
IMPORT_RELOAD: '✅ 데이터를 성공적으로 가져왔습니다! 앱을 다시 시작합니다.',
WEATHER_LOCATION_UPDATED: '🌦️ 날씨 지역 정보가 업데이트되었습니다.'
},
ERROR: {
FOLDER_EXISTS: name => `🤔 '${name}' 폴더는 이미 존재해요.`,
ADD_NOTE_PROMPT: '💡 노트를 추가하려면 먼저 실제 폴더를 선택해주세요.',
RESTORE_FAILED_NO_FOLDER: '🤔 원본 폴더를 찾을 수 없습니다. 먼저 폴더를 복원해주세요.',
EMPTY_NAME_ERROR: '🤔 이름은 비워둘 수 없어요.',
DUPLICATE_NAME_ERROR: name => `🤔 '${name}' 이름이 이미 존재합니다.`,
EXPORT_FAILURE: '📤❌ 데이터 내보내기 실패.', IMPORT_FAILURE: err => `📥❌ 가져오기 실패: ${err.message}`,
IMPORT_SIZE_EXCEEDED: '📥❌ 파일 크기가 5MB를 초과할 수 없습니다.',
INVALID_FONT_NAME: '🤔 유효하지 않은 글꼴 이름입니다. 기본값으로 복원됩니다.',
WEATHER_CITY_NOT_FOUND: '🌦️ 도시를 찾을 수 없습니다. 다른 이름으로 검색해보세요.',
INVALID_LATITUDE: '🤔 유효하지 않은 위도 값입니다. (-90 ~ 90)',
INVALID_LONGITUDE: '🤔 유효하지 않은 경도 값입니다. (-180 ~ 180)',
RESERVED_NAME: '🚫 시스템에서 사용하는 이름으로는 변경할 수 없습니다.',
// --- [CRITICAL BUG FIX] START ---
RENAME_CONFLICT_ON_RECOVERY: name => `'${name}'(으)로의 이름 변경을 복원할 수 없습니다. 해당 이름이 이미 다른 곳에서 사용 중입니다. 복원하려면 새 이름을 입력해주세요.`,
RENAME_RECOVERY_CANCELED: '이름 변경 복원이 취소되었습니다.',
// --- [CRITICAL BUG FIX] END ---
},
CONFIRM: {
PERM_DELETE: name => `💥 '${name}' 항목을 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없어요! 😱`,
EMPTY_TRASH: count => `🗑️ 휴지통에 있는 모든 항목(${count}개)을 영구적으로 삭제할까요?`,
IMPORT_DATA: '⚠️ 데이터를 가져오면 현재 모든 데이터가 교체됩니다. 계속하시겠습니까?',
}
}
};
export let state = {
// --- 핵심 데이터 ---
folders: [],
trash: [],
favorites: new Set(),
lastSavedTimestamp: null,
// --- UI/세션 상태 ---
activeFolderId: null,
activeNoteId: null,
searchTerm: '',
noteSortOrder: 'updatedAt_desc',
lastActiveNotePerFolder: {},
preSearchActiveNoteId: null,
dateFilter: null,
renamingItemId: null,
isMarkdownView: false,
// --- 파생/캐시 데이터 ---
noteMap: new Map(),
totalNoteCount: 0,
noteCreationDates: new Set(),
_virtualFolderCache: { all: null, recent: null, favorites: null, trash: null },
// --- 실시간 상태 플래그 ---
isDirty: false,
dirtyNoteId: null,
isPerformingOperation: false,
};
// --- 구독 및 상태 변경 알림 로직 ---
const subscribers = new Set();
export const subscribe = (callback) => {
subscribers.add(callback);
return () => subscribers.delete(callback);
};
const notify = () => {
subscribers.forEach(callback => {
try {
callback();
} catch (error) {
console.error("A subscriber failed during notification:", error);
}
});
};
export const buildNoteMap = () => {
state._virtualFolderCache = { all: null, recent: null, favorites: null, trash: null };
state.noteMap.clear();
for (const folder of state.folders) {
for (const note of folder.notes) {
state.noteMap.set(note.id, { note, folderId: folder.id });
}
}
};
export const setState = (newState) => {
// [BUG FIX] folders 데이터가 업데이트되었는지 확인합니다.
const foldersJustUpdated = 'folders' in newState;
Object.assign(state, newState);
// [BUG FIX] folders가 변경되었다면, UI에 알리기(notify) 전에 noteMap을 먼저 재구축합니다.
// 이렇게 하면 상태와 파생 데이터(noteMap)의 일관성이 항상 보장됩니다.
if (foldersJustUpdated) {
buildNoteMap();
}
notify();
};
// --- 데이터 검색 헬퍼 ---
const _findNoteInState = (id) => {
const entry = state.noteMap.get(id);
if (!entry) return { item: null, folder: null, index: -1 };
const { item: folder } = _findFolderInState(entry.folderId);
if (!folder) return { item: null, folder: null, index: -1 };
const index = folder.notes.findIndex(n => n.id === id);
return { item: entry.note, folder, index };
};
const _findFolderInState = (id) => {
const index = state.folders.findIndex(f => f.id === id);
return { item: state.folders[index], index };
};
const _findInTrash = (id) => {
const index = state.trash.findIndex(item => item.id === id);
return { item: state.trash[index], index };
};
const _findInVirtualFolders = (id) => {
const virtualFolderDef = Object.values(CONSTANTS.VIRTUAL_FOLDERS).find(vf => vf.id === id);
if (!virtualFolderDef) return null;
const cacheKey = virtualFolderDef.id.split('-')[0];
let notes = state._virtualFolderCache[cacheKey];
if (!notes) {
notes = virtualFolderDef.getNotes(state);
state._virtualFolderCache[cacheKey] = notes;
}
return {
item: { ...virtualFolderDef, notes, isVirtual: true },
index: -1
};
}
const findItem = (id, type) => {
if (!id) return { item: null, index: -1, folder: null, isInTrash: false };
if (type === CONSTANTS.ITEM_TYPE.FOLDER) {
const virtualResult = _findInVirtualFolders(id);
if (virtualResult) return { ...virtualResult, isInTrash: false };
}
const { item: trashedItem, index: trashIndex } = _findInTrash(id);
if (trashedItem) {
return { item: trashedItem, index: trashIndex, folder: null, isInTrash: true };
}
if (type === CONSTANTS.ITEM_TYPE.FOLDER) {
const { item, index } = _findFolderInState(id);
return { item, index, folder: null, isInTrash: false };
}
if (type === CONSTANTS.ITEM_TYPE.NOTE) {
const { item, folder, index } = _findNoteInState(id);
return { item, index, folder, isInTrash: false };
}
return { item: null, index: -1, folder: null, isInTrash: false };
};
export const findFolder = (id) => findItem(id, CONSTANTS.ITEM_TYPE.FOLDER);
export const findNote = (id) => findItem(id, CONSTANTS.ITEM_TYPE.NOTE);
// --- [버그 수정] 순환 참조 해결을 위해 storage.js에서 이동 ---
/**
* 앱의 전체 상태(활성 노트, 휴지통)를 확인하여
* 충돌하지 않는 고유한 ID를 생성하고 반환합니다.
*/
export const generateUniqueId = (prefix, existingIds) => {
// [안정성 강화] existingIds가 유효한 Set이 아닌 경우(undefined, null 등) 예외를 방지하기 위한 방어 코드
const checkSet = (existingIds && typeof existingIds.has === 'function') ? existingIds : new Set();
// crypto.randomUUID가 있으면 사용 (더 강력한 고유성)
if (typeof crypto?.randomUUID === 'function') {
let id;
do {
// [BUG FIX] 생성된 UUID 앞에 prefix를 일관되게 추가합니다.
id = `${prefix}${crypto.randomUUID()}`;
} while (checkSet.has(id));
return id;
}
// Fallback: 기존 방식보다 고유성을 강화
let id;
do {
id = `${prefix}${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
} while (checkSet.has(id));
return id;
};