feat(web): extract ui-services package

This commit is contained in:
Karol Sójko
2022-08-04 15:13:30 +02:00
parent c72a407095
commit 7e251262d7
161 changed files with 1105 additions and 824 deletions

View File

@@ -0,0 +1,66 @@
import { sanitizeHtmlString } from '@standardnotes/utils'
import { SKAlert } from '@standardnotes/styles'
/** @returns a promise resolving to true if the user confirmed, false if they canceled */
export function confirmDialog({
text,
title,
confirmButtonText = 'Confirm',
cancelButtonText = 'Cancel',
confirmButtonStyle = 'info',
}: {
text: string
title?: string
confirmButtonText?: string
cancelButtonText?: string
confirmButtonStyle?: 'danger' | 'info'
}) {
return new Promise<boolean>((resolve) => {
const alert = new SKAlert({
title: title && sanitizeHtmlString(title),
text: sanitizeHtmlString(text),
buttons: [
{
text: cancelButtonText,
style: 'neutral',
action() {
resolve(false)
},
},
{
text: confirmButtonText,
style: confirmButtonStyle,
action() {
resolve(true)
},
},
],
})
alert.present()
})
}
export function alertDialog({
title,
text,
closeButtonText = 'OK',
}: {
title?: string
text: string
closeButtonText?: string
}) {
return new Promise<void>((resolve) => {
const alert = new SKAlert({
title: title && sanitizeHtmlString(title),
text: sanitizeHtmlString(text),
buttons: [
{
text: closeButtonText,
style: 'neutral',
action: resolve,
},
],
})
alert.present()
})
}

View File

@@ -0,0 +1,38 @@
import { AlertService, ButtonType } from '@standardnotes/services'
import { sanitizeHtmlString } from '@standardnotes/utils'
import { SKAlert } from '@standardnotes/styles'
import { alertDialog, confirmDialog } from './Functions'
export class WebAlertService extends AlertService {
alert(text: string, title?: string, closeButtonText?: string) {
return alertDialog({ text, title, closeButtonText })
}
confirm(
text: string,
title?: string,
confirmButtonText?: string,
confirmButtonType?: ButtonType,
cancelButtonText?: string,
): Promise<boolean> {
return confirmDialog({
text,
title,
confirmButtonText,
cancelButtonText,
confirmButtonStyle: confirmButtonType === ButtonType.Danger ? 'danger' : 'info',
})
}
blockingDialog(text: string, title?: string) {
const alert = new SKAlert({
title: title && sanitizeHtmlString(title),
text: sanitizeHtmlString(text),
buttons: [],
})
alert.present()
return () => {
alert.dismiss()
}
}
}

View File

@@ -0,0 +1,177 @@
import { parseFileName } from '@standardnotes/filepicker'
import {
BackupFile,
ContentType,
BackupFileDecryptedContextualPayload,
NoteContent,
EncryptedItemInterface,
SNApplication,
} from '@standardnotes/snjs'
function sanitizeFileName(name: string): string {
return name.trim().replace(/[.\\/:"?*|<>]/g, '_')
}
function zippableFileName(name: string, suffix = '', format = 'txt'): string {
const sanitizedName = sanitizeFileName(name)
const nameEnd = suffix + '.' + format
const maxFileNameLength = 100
return sanitizedName.slice(0, maxFileNameLength - nameEnd.length) + nameEnd
}
type ZippableData = {
name: string
content: Blob
}[]
type ObjectURL = string
export class ArchiveManager {
private readonly application: SNApplication
private textFile?: string
constructor(application: SNApplication) {
this.application = application
}
public async getMimeType(ext: string) {
return (await import('@zip.js/zip.js')).getMimeType(ext)
}
public async downloadBackup(encrypted: boolean): Promise<void> {
const data = encrypted
? await this.application.createEncryptedBackupFile()
: await this.application.createDecryptedBackupFile()
if (!data) {
return
}
const blobData = new Blob([JSON.stringify(data, null, 2)], {
type: 'text/json',
})
if (encrypted) {
this.downloadData(blobData, `Standard Notes Encrypted Backup and Import File - ${this.formattedDate()}.txt`)
} else {
this.downloadZippedDecryptedItems(data).catch(console.error)
}
}
private formattedDate() {
const string = `${new Date()}`
// Match up to the first parenthesis, i.e do not include '(Central Standard Time)'
const matches = string.match(/^(.*?) \(/)
if (matches && matches.length >= 2) {
return matches[1]
}
return string
}
private async downloadZippedDecryptedItems(data: BackupFile) {
const zip = await import('@zip.js/zip.js')
const zipWriter = new zip.ZipWriter(new zip.BlobWriter('application/zip'))
const items = data.items
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: 'text/plain',
})
const fileName = zippableFileName('Standard Notes Backup and Import File')
await zipWriter.add(fileName, new zip.BlobReader(blob))
let index = 0
const nextFile = async () => {
const item = items[index]
let name, contents
if (item.content_type === ContentType.Note) {
const note = item as BackupFileDecryptedContextualPayload<NoteContent>
name = note.content.title
contents = note.content.text
} else {
name = item.content_type
contents = JSON.stringify(item.content, null, 2)
}
if (!name) {
name = ''
}
const blob = new Blob([contents], { type: 'text/plain' })
const fileName =
`Items/${sanitizeFileName(item.content_type)}/` + zippableFileName(name, `-${item.uuid.split('-')[0]}`)
await zipWriter.add(fileName, new zip.BlobReader(blob))
index++
if (index < items.length) {
await nextFile()
} else {
const finalBlob = await zipWriter.close()
this.downloadData(finalBlob, `Standard Notes Backup - ${this.formattedDate()}.zip`)
}
}
await nextFile()
}
async zipData(data: ZippableData): Promise<Blob> {
const zip = await import('@zip.js/zip.js')
const writer = new zip.ZipWriter(new zip.BlobWriter('application/zip'))
const filenameCounts: Record<string, number> = {}
for (let i = 0; i < data.length; i++) {
const file = data[i]
const { name, ext } = parseFileName(file.name)
filenameCounts[file.name] = filenameCounts[file.name] == undefined ? 0 : filenameCounts[file.name] + 1
const currentFileNameIndex = filenameCounts[file.name]
await writer.add(
zippableFileName(name, currentFileNameIndex > 0 ? ` - ${currentFileNameIndex}` : '', ext),
new zip.BlobReader(file.content),
)
}
const zipFileAsBlob = await writer.close()
return zipFileAsBlob
}
async downloadDataAsZip(data: ZippableData) {
const zipFileAsBlob = await this.zipData(data)
this.downloadData(zipFileAsBlob, `Standard Notes Export - ${this.formattedDate()}.zip`)
}
private hrefForData(data: Blob) {
// If we are replacing a previously generated file we need to
// manually revoke the object URL to avoid memory leaks.
if (this.textFile) {
window.URL.revokeObjectURL(this.textFile)
}
this.textFile = window.URL.createObjectURL(data)
// returns a URL you can use as a href
return this.textFile
}
downloadData(data: Blob | ObjectURL, fileName: string): void {
const link = document.createElement('a')
link.setAttribute('download', fileName)
link.href = typeof data === 'string' ? data : this.hrefForData(data)
document.body.appendChild(link)
link.click()
link.remove()
}
downloadEncryptedItem(item: EncryptedItemInterface) {
this.downloadData(new Blob([JSON.stringify(item.payload.ejected())]), `${item.uuid}.txt`)
}
downloadEncryptedItems(items: EncryptedItemInterface[]) {
const data = JSON.stringify(items.map((i) => i.payload.ejected()))
this.downloadData(new Blob([data]), 'errored-items.txt')
}
}

View File

@@ -0,0 +1,212 @@
import { removeFromArray } from '@standardnotes/snjs'
export enum KeyboardKey {
Tab = 'Tab',
Backspace = 'Backspace',
Up = 'ArrowUp',
Down = 'ArrowDown',
Left = 'ArrowLeft',
Right = 'ArrowRight',
Enter = 'Enter',
Escape = 'Escape',
Home = 'Home',
End = 'End',
}
export enum KeyboardModifier {
Shift = 'Shift',
Ctrl = 'Control',
/** ⌘ key on Mac, ⊞ key on Windows */
Meta = 'Meta',
Alt = 'Alt',
}
enum KeyboardKeyEvent {
Down = 'KeyEventDown',
Up = 'KeyEventUp',
}
type KeyboardObserver = {
key?: KeyboardKey | string
modifiers?: KeyboardModifier[]
onKeyDown?: (event: KeyboardEvent) => void
onKeyUp?: (event: KeyboardEvent) => void
element?: HTMLElement
elements?: HTMLElement[]
notElement?: HTMLElement
notElementIds?: string[]
notTags?: string[]
}
export class IOService {
readonly activeModifiers = new Set<KeyboardModifier>()
private observers: KeyboardObserver[] = []
constructor(private isMac: boolean) {
window.addEventListener('keydown', this.handleKeyDown)
window.addEventListener('keyup', this.handleKeyUp)
window.addEventListener('blur', this.handleWindowBlur)
}
public deinit() {
this.observers.length = 0
window.removeEventListener('keydown', this.handleKeyDown)
window.removeEventListener('keyup', this.handleKeyUp)
window.removeEventListener('blur', this.handleWindowBlur)
;(this.handleKeyDown as unknown) = undefined
;(this.handleKeyUp as unknown) = undefined
;(this.handleWindowBlur as unknown) = undefined
}
private addActiveModifier = (modifier: KeyboardModifier | undefined): void => {
if (!modifier) {
return
}
switch (modifier) {
case KeyboardModifier.Meta: {
if (this.isMac) {
this.activeModifiers.add(modifier)
}
break
}
case KeyboardModifier.Ctrl: {
if (!this.isMac) {
this.activeModifiers.add(modifier)
}
break
}
default: {
this.activeModifiers.add(modifier)
break
}
}
}
private removeActiveModifier = (modifier: KeyboardModifier | undefined): void => {
if (!modifier) {
return
}
this.activeModifiers.delete(modifier)
}
public cancelAllKeyboardModifiers = (): void => {
this.activeModifiers.clear()
}
public handleComponentKeyDown = (modifier: KeyboardModifier | undefined): void => {
this.addActiveModifier(modifier)
}
public handleComponentKeyUp = (modifier: KeyboardModifier | undefined): void => {
this.removeActiveModifier(modifier)
}
private handleKeyDown = (event: KeyboardEvent): void => {
this.updateAllModifiersFromEvent(event)
this.notifyObserver(event, KeyboardKeyEvent.Down)
}
private handleKeyUp = (event: KeyboardEvent): void => {
this.updateAllModifiersFromEvent(event)
this.notifyObserver(event, KeyboardKeyEvent.Up)
}
private updateAllModifiersFromEvent(event: KeyboardEvent): void {
for (const modifier of Object.values(KeyboardModifier)) {
if (event.getModifierState(modifier)) {
this.addActiveModifier(modifier)
} else {
this.removeActiveModifier(modifier)
}
}
}
handleWindowBlur = (): void => {
for (const modifier of this.activeModifiers) {
this.activeModifiers.delete(modifier)
}
}
modifiersForEvent(event: KeyboardEvent): KeyboardModifier[] {
const allModifiers = Object.values(KeyboardModifier)
const eventModifiers = allModifiers.filter((modifier) => {
// For a modifier like ctrlKey, must check both event.ctrlKey and event.key.
// That's because on keyup, event.ctrlKey would be false, but event.key == Control would be true.
const matches =
((event.ctrlKey || event.key === KeyboardModifier.Ctrl) && modifier === KeyboardModifier.Ctrl) ||
((event.metaKey || event.key === KeyboardModifier.Meta) && modifier === KeyboardModifier.Meta) ||
((event.altKey || event.key === KeyboardModifier.Alt) && modifier === KeyboardModifier.Alt) ||
((event.shiftKey || event.key === KeyboardModifier.Shift) && modifier === KeyboardModifier.Shift)
return matches
})
return eventModifiers
}
private eventMatchesKeyAndModifiers(
event: KeyboardEvent,
key: KeyboardKey | string | undefined,
modifiers: KeyboardModifier[] = [],
): boolean {
const eventModifiers = this.modifiersForEvent(event)
if (eventModifiers.length !== modifiers.length) {
return false
}
for (const modifier of modifiers) {
if (!eventModifiers.includes(modifier)) {
return false
}
}
// Modifers match, check key
if (!key) {
return true
}
// In the browser, shift + f results in key 'f', but in Electron, shift + f results in 'F'
// In our case we don't differentiate between the two.
return key.toLowerCase() === event.key.toLowerCase()
}
notifyObserver(event: KeyboardEvent, keyEvent: KeyboardKeyEvent): void {
const target = event.target as HTMLElement
for (const observer of this.observers) {
if (observer.element && event.target !== observer.element) {
continue
}
if (observer.elements && !observer.elements.includes(target)) {
continue
}
if (observer.notElement && observer.notElement === event.target) {
continue
}
if (observer.notElementIds && observer.notElementIds.includes(target.id)) {
continue
}
if (observer.notTags && observer.notTags.includes(target.tagName)) {
continue
}
if (this.eventMatchesKeyAndModifiers(event, observer.key, observer.modifiers)) {
const callback = keyEvent === KeyboardKeyEvent.Down ? observer.onKeyDown : observer.onKeyUp
if (callback) {
callback(event)
}
}
}
}
addKeyObserver(observer: KeyboardObserver): () => void {
this.observers.push(observer)
const thislessObservers = this.observers
return () => {
removeFromArray(thislessObservers, observer)
}
}
}

View File

@@ -0,0 +1,129 @@
import { ApplicationService } from '@standardnotes/snjs'
const MILLISECONDS_PER_SECOND = 1000
const POLL_INTERVAL = 50
const LockInterval = {
None: 0,
Immediate: 1,
OneMinute: 60 * MILLISECONDS_PER_SECOND,
FiveMinutes: 300 * MILLISECONDS_PER_SECOND,
OneHour: 3600 * MILLISECONDS_PER_SECOND,
}
const STORAGE_KEY_AUTOLOCK_INTERVAL = 'AutoLockIntervalKey'
export class AutolockService extends ApplicationService {
private pollInterval: any
private lastFocusState?: 'hidden' | 'visible'
private lockAfterDate?: Date
override onAppLaunch() {
this.beginPolling()
return super.onAppLaunch()
}
override deinit() {
this.cancelAutoLockTimer()
if (this.pollInterval) {
clearInterval(this.pollInterval)
}
super.deinit()
}
private lockApplication() {
if (!this.application.hasPasscode()) {
throw Error('Attempting to lock application with no passcode')
}
this.application.lock().catch(console.error)
}
async setAutoLockInterval(interval: number) {
return this.application.setValue(STORAGE_KEY_AUTOLOCK_INTERVAL, interval)
}
async getAutoLockInterval() {
const interval = (await this.application.getValue(STORAGE_KEY_AUTOLOCK_INTERVAL)) as number
if (interval) {
return interval
} else {
return LockInterval.None
}
}
async deleteAutolockPreference() {
await this.application.removeValue(STORAGE_KEY_AUTOLOCK_INTERVAL)
this.cancelAutoLockTimer()
}
/**
* Verify document is in focus every so often as visibilitychange event is
* not triggered on a typical window blur event but rather on tab changes.
*/
beginPolling() {
this.pollInterval = setInterval(async () => {
const locked = await this.application.isLocked()
if (!locked && this.lockAfterDate && new Date() > this.lockAfterDate) {
this.lockApplication()
}
const hasFocus = document.hasFocus()
if (hasFocus && this.lastFocusState === 'hidden') {
this.documentVisibilityChanged(true).catch(console.error)
} else if (!hasFocus && this.lastFocusState === 'visible') {
this.documentVisibilityChanged(false).catch(console.error)
}
/* Save this to compare against next time around */
this.lastFocusState = hasFocus ? 'visible' : 'hidden'
}, POLL_INTERVAL)
}
getAutoLockIntervalOptions() {
return [
{
value: LockInterval.None,
label: 'Off',
},
{
value: LockInterval.Immediate,
label: 'Immediately',
},
{
value: LockInterval.OneMinute,
label: '1m',
},
{
value: LockInterval.FiveMinutes,
label: '5m',
},
{
value: LockInterval.OneHour,
label: '1h',
},
]
}
async documentVisibilityChanged(visible: boolean) {
if (visible) {
this.cancelAutoLockTimer()
} else {
this.beginAutoLockTimer().catch(console.error)
}
}
async beginAutoLockTimer() {
const interval = await this.getAutoLockInterval()
if (interval === LockInterval.None) {
return
}
const addToNow = (seconds: number) => {
const date = new Date()
date.setSeconds(date.getSeconds() + seconds)
return date
}
this.lockAfterDate = addToNow(interval / MILLISECONDS_PER_SECOND)
}
cancelAutoLockTimer() {
this.lockAfterDate = undefined
}
}

View File

@@ -0,0 +1,26 @@
export enum StorageKey {
AnonymousUserId = 'AnonymousUserId',
ShowBetaWarning = 'ShowBetaWarning',
ShowNoAccountWarning = 'ShowNoAccountWarning',
FilesNavigationEnabled = 'FilesNavigationEnabled',
}
export type StorageValue = {
[StorageKey.AnonymousUserId]: string
[StorageKey.ShowBetaWarning]: boolean
[StorageKey.ShowNoAccountWarning]: boolean
[StorageKey.FilesNavigationEnabled]: boolean
}
export const storage = {
get<K extends StorageKey>(key: K): StorageValue[K] | null {
const value = localStorage.getItem(key)
return value ? JSON.parse(value) : null
},
set<K extends StorageKey>(key: K, value: StorageValue[K]): void {
localStorage.setItem(key, JSON.stringify(value))
},
remove(key: StorageKey): void {
localStorage.removeItem(key)
},
}

View File

@@ -0,0 +1,306 @@
import {
StorageValueModes,
ApplicationService,
SNTheme,
removeFromArray,
ApplicationEvent,
ContentType,
UuidString,
FeatureStatus,
PrefKey,
CreateDecryptedLocalStorageContextPayload,
InternalEventBus,
PayloadEmitSource,
LocalStorageDecryptedContextualPayload,
WebApplicationInterface,
} from '@standardnotes/snjs'
import { dismissToast, ToastType, addTimedToast } from '@standardnotes/toast'
const CachedThemesKey = 'cachedThemes'
const TimeBeforeApplyingColorScheme = 5
const DefaultThemeIdentifier = 'Default'
export class ThemeManager extends ApplicationService {
private activeThemes: UuidString[] = []
private unregisterDesktop?: () => void
private unregisterStream!: () => void
private lastUseDeviceThemeSettings = false
constructor(application: WebApplicationInterface) {
super(application, new InternalEventBus())
this.colorSchemeEventHandler = this.colorSchemeEventHandler.bind(this)
}
override async onAppStart() {
super.onAppStart().catch(console.error)
this.registerObservers()
}
override async onAppEvent(event: ApplicationEvent) {
super.onAppEvent(event).catch(console.error)
switch (event) {
case ApplicationEvent.SignedOut: {
this.deactivateAllThemes()
this.activeThemes = []
this.application?.removeValue(CachedThemesKey, StorageValueModes.Nonwrapped).catch(console.error)
break
}
case ApplicationEvent.StorageReady: {
await this.activateCachedThemes()
break
}
case ApplicationEvent.FeaturesUpdated: {
this.handleFeaturesUpdated()
break
}
case ApplicationEvent.Launched: {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', this.colorSchemeEventHandler)
break
}
case ApplicationEvent.PreferencesChanged: {
this.handlePreferencesChangeEvent()
break
}
}
}
private handlePreferencesChangeEvent(): void {
const useDeviceThemeSettings = this.application.getPreference(PrefKey.UseSystemColorScheme, false)
if (useDeviceThemeSettings !== this.lastUseDeviceThemeSettings) {
this.lastUseDeviceThemeSettings = useDeviceThemeSettings
}
if (useDeviceThemeSettings) {
const prefersDarkColorScheme = window.matchMedia('(prefers-color-scheme: dark)')
this.setThemeAsPerColorScheme(prefersDarkColorScheme.matches)
}
}
get webApplication() {
return this.application as WebApplicationInterface
}
override deinit() {
this.activeThemes.length = 0
this.unregisterDesktop?.()
this.unregisterStream()
;(this.unregisterDesktop as unknown) = undefined
;(this.unregisterStream as unknown) = undefined
window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', this.colorSchemeEventHandler)
super.deinit()
}
private handleFeaturesUpdated(): void {
let hasChange = false
for (const themeUuid of this.activeThemes) {
const theme = this.application.items.findItem(themeUuid) as SNTheme
if (!theme) {
this.deactivateTheme(themeUuid)
hasChange = true
} else {
const status = this.application.features.getFeatureStatus(theme.identifier)
if (status !== FeatureStatus.Entitled) {
if (theme.active) {
this.application.mutator.toggleTheme(theme).catch(console.error)
} else {
this.deactivateTheme(theme.uuid)
}
hasChange = true
}
}
}
const activeThemes = (this.application.items.getItems(ContentType.Theme) as SNTheme[]).filter(
(theme) => theme.active,
)
for (const theme of activeThemes) {
if (!this.activeThemes.includes(theme.uuid)) {
this.activateTheme(theme)
hasChange = true
}
}
if (hasChange) {
this.cacheThemeState().catch(console.error)
}
}
private colorSchemeEventHandler(event: MediaQueryListEvent) {
this.setThemeAsPerColorScheme(event.matches)
}
private showColorSchemeToast(setThemeCallback: () => void) {
const [toastId, intervalId] = addTimedToast(
{
type: ToastType.Regular,
message: (timeRemaining) => `Applying system color scheme in ${timeRemaining}s...`,
actions: [
{
label: 'Keep current theme',
handler: () => {
dismissToast(toastId)
clearInterval(intervalId)
},
},
{
label: 'Apply now',
handler: () => {
dismissToast(toastId)
clearInterval(intervalId)
setThemeCallback()
},
},
],
},
setThemeCallback,
TimeBeforeApplyingColorScheme,
)
}
private setThemeAsPerColorScheme(prefersDarkColorScheme: boolean) {
const preference = prefersDarkColorScheme ? PrefKey.AutoDarkThemeIdentifier : PrefKey.AutoLightThemeIdentifier
const themes = this.application.items
.getDisplayableComponents()
.filter((component) => component.isTheme()) as SNTheme[]
const activeTheme = themes.find((theme) => theme.active && !theme.isLayerable())
const activeThemeIdentifier = activeTheme ? activeTheme.identifier : DefaultThemeIdentifier
const themeIdentifier = this.application.getPreference(preference, DefaultThemeIdentifier) as string
const setTheme = () => {
if (themeIdentifier === DefaultThemeIdentifier && activeTheme) {
this.application.mutator.toggleTheme(activeTheme).catch(console.error)
} else {
const theme = themes.find((theme) => theme.package_info.identifier === themeIdentifier)
if (theme && !theme.active) {
this.application.mutator.toggleTheme(theme).catch(console.error)
}
}
}
const isPreferredThemeNotActive = activeThemeIdentifier !== themeIdentifier
if (isPreferredThemeNotActive) {
this.showColorSchemeToast(setTheme)
}
}
private async activateCachedThemes() {
const cachedThemes = await this.getCachedThemes()
for (const theme of cachedThemes) {
this.activateTheme(theme, true)
}
}
private registerObservers() {
this.unregisterDesktop = this.webApplication.getDesktopService()?.registerUpdateObserver((component) => {
if (component.active && component.isTheme()) {
this.deactivateTheme(component.uuid)
setTimeout(() => {
this.activateTheme(component as SNTheme)
this.cacheThemeState().catch(console.error)
}, 10)
}
})
this.unregisterStream = this.application.streamItems(ContentType.Theme, ({ changed, inserted, source }) => {
const items = changed.concat(inserted)
const themes = items as SNTheme[]
for (const theme of themes) {
if (theme.active) {
this.activateTheme(theme)
} else {
this.deactivateTheme(theme.uuid)
}
}
if (source !== PayloadEmitSource.LocalRetrieved) {
this.cacheThemeState().catch(console.error)
}
})
}
public deactivateAllThemes() {
const activeThemes = this.activeThemes.slice()
for (const uuid of activeThemes) {
this.deactivateTheme(uuid)
}
}
private activateTheme(theme: SNTheme, skipEntitlementCheck = false) {
if (this.activeThemes.find((uuid) => uuid === theme.uuid)) {
return
}
if (
!skipEntitlementCheck &&
this.application.features.getFeatureStatus(theme.identifier) !== FeatureStatus.Entitled
) {
return
}
const url = this.application.componentManager.urlForComponent(theme)
if (!url) {
return
}
this.activeThemes.push(theme.uuid)
const link = document.createElement('link')
link.href = url
link.type = 'text/css'
link.rel = 'stylesheet'
link.media = 'screen,print'
link.id = theme.uuid
document.getElementsByTagName('head')[0].appendChild(link)
}
private deactivateTheme(uuid: string) {
const element = document.getElementById(uuid) as HTMLLinkElement
if (element) {
element.disabled = true
element.parentNode?.removeChild(element)
}
removeFromArray(this.activeThemes, uuid)
}
private async cacheThemeState() {
const themes = this.application.items.findItems(this.activeThemes) as SNTheme[]
const mapped = themes.map((theme) => {
const payload = theme.payloadRepresentation()
return CreateDecryptedLocalStorageContextPayload(payload)
})
return this.application.setValue(CachedThemesKey, mapped, StorageValueModes.Nonwrapped)
}
private async getCachedThemes() {
const cachedThemes = (await this.application.getValue(
CachedThemesKey,
StorageValueModes.Nonwrapped,
)) as LocalStorageDecryptedContextualPayload[]
if (cachedThemes) {
const themes = []
for (const cachedTheme of cachedThemes) {
const payload = this.application.items.createPayloadFromObject(cachedTheme)
const theme = this.application.items.createItemFromPayload(payload) as SNTheme
themes.push(theme)
}
return themes
} else {
return []
}
}
}

View File

@@ -0,0 +1,7 @@
export * from './Alert/Functions'
export * from './Alert/WebAlertService'
export * from './Archive/ArchiveManager'
export * from './IO/IOService'
export * from './Security/AutolockService'
export * from './Storage/LocalStorage'
export * from './Theme/ThemeManager'