import SDKAdapter from '../core/adapter.js' import { SDKCollection, SDKFunctionalCollection } from '../core/collection.js' import { SDKModel } from '../core/model.js' import { SDKModelSchema } from '../core/schema.js' import { nanoid } from '../core/transport.js' import stylesheetSchema from '../schemas/stylesheet.json' assert { type: 'json' } import type { SDK } from '../sdk.js' import * as Style from '../style/index.js' import * as CSS from '../utils/css.js' import { cloneDeep, isDeepEquals } from '../utils/object.js' import { Atleast, RequiredKeys } from '../utils/typescript.js' import type { LibraryModel, NanoId } from './libraries.js' /** Defines the schema for Stylesheet. */ export interface Stylesheet { /** The identifier of the Library that Stylesheet belongs to */ libraryId: NanoId /** The identifier of the Stylesheet */ id: NanoId /** The source of the Stylesheet */ source: any /** * The format version of the Stylesheet. * * @minimum 0 * @TJS-type integer */ formatVersion: number /** The status of the Stylesheet */ status: 'published' | 'draft' | 'staged' | 'saved' /** * The numeric revision of the Stylesheet. * * @minimum 0 * @TJS-type integer */ revision: number /** The timestamp the Stylesheet was created at */ createdAt: Date /** The timestamp when the Stylesheet was last modified at */ modifiedAt: Date } const FORMAT_VERSION = 30 export function getStylesheetDefaults(sdk: SDK) { const now = new Date() return { id: 'default_screen', modifiedAt: now, createdAt: now, formatVersion: FORMAT_VERSION, source: Style.Presets.all, revision: 0, status: 'published' } } export class Stylesheets extends SDKAdapter { fetch(stylesheet: Pick): Promise { const { libraryId } = stylesheet return this.sdk.fetchJSON(`/libraries/${libraryId}/stylesheets`) } get(stylesheet: Pick): Promise { const { libraryId, status } = stylesheet return this.sdk.fetchJSON(`/libraries/${libraryId}/stylesheets?status=${status}`).then((stylesheets) => { return stylesheets[0] }) } post(stylesheet: Atleast) { return this.put(stylesheet) } put(stylesheet: Atleast): Promise { const { libraryId } = stylesheet return this.sdk.fetchJSON(`/libraries/${libraryId}/stylesheets`, { method: 'PUT', body: JSON.stringify(stylesheet) }) } prune({ libraryId }: Partial> = {}) { // not implemented on clientside } } export interface StylesheetImplicit { libraryId: LibraryModel['id'] library?: LibraryModel } export type StylesheetMinimal = RequiredKeys< Stylesheet, typeof getStylesheetDefaults, T > export interface StylesheetParams extends Stylesheet, StylesheetImplicit {} export interface StylesheetModel extends StylesheetParams {} export class StylesheetModel extends SDKModel implements StylesheetParams { _source: Style.Rule[]; ['constructor']: typeof StylesheetModel get adapter() { return this.sdk.Stylesheets } rules: ReturnType static getCSS(rules: Style.Rule[]) { return CSS.toText((rules || []).concat(Style.Presets.internal)) } _css: string get css() { return (this._css ||= StylesheetModel.getCSS(this.source)) } set css(css: string) { this._css = css } getId() { return this.libraryId + this.id + ':' + this.revision + ':' + this.status } getSourcePath() { return `/${this.libraryId}/${this.status}.css` } defineProperties() { super.defineProperties() this.sdk.utils.defineCollectionAccessor(this, 'rules', this.constructRules(), {}, this.getHiddenProps()) this.sdk.utils.defineAccessor(this, 'source', {}, this.getHiddenProps()) this.sdk.utils.defineAccessor(this, 'css', {}, this.getHiddenProps()) } /** Rules is an observable collection that combines `source` styles + `internal` styles. Only source styles are saved on backend. */ constructRules() { var stylesheet = this const rules = new SDKFunctionalCollection>(Style.Rule, (s) => s.details.id) // On each change fix & sort styles in collection rules.onCollectionChange = function () { this.splice(0, this.length, ...stylesheet.sortStyles(this)) return this } return rules } sortStyles(rules: Style.Rule[] = this.rules) { const fixedStyles = rules.slice() // make fonts show up on top, otherwise @imports break const desiredOrder = ['breakpoint', 'font'] as Style.Type[] // Reorder styles to bubble up the fonts const sortedStyles = fixedStyles.sort((a, b) => { const ax = desiredOrder.indexOf(a.type) const bx = desiredOrder.indexOf(b.type) if (a.type == 'breakpoint' && b.type == 'breakpoint') { return b.props.minWidth - a.props.minWidth } return bx - ax }) return sortedStyles } getHiddenProps(): string[] { return ['library', 'rules', 'css'] } getDefaults(sdk: SDK) { return getStylesheetDefaults(sdk) } getCollectionItems(style: Style.Rule) { if (style.type !== 'collection') return [] return this.rules.filter( (s) => s.details.collectionId === style.details.id && !s.details.isHidden && !s.details.elementId ) } getStatus(): Stylesheet['status'] { return this.status } getBreakpointForWidth(width: number, breakpoints = Style.Set.filterByType(this.rules, 'breakpoint')) { if (!isFinite(width)) width = 9999 return breakpoints.sort((a, b) => { return ( Math.min(Math.abs(a.props.minWidth - width), Math.abs(a.props.maxWidth - width)) - Math.min(Math.abs(b.props.minWidth - width), Math.abs(b.props.maxWidth - width)) ) })[0] } isUpToDateWith(status: Stylesheet['status']) { return this.isSameContent(this.getCurrent(status)) } isSameContent(stylesheet: StylesheetModel) { return isDeepEquals(stylesheet?.rules || {}, this.rules) && stylesheet?.formatVersion == this.formatVersion } /** Get highest revision of a stylesheet matching current status(es) if given */ getCurrent(statuses?: Stylesheet['status'][] | Stylesheet['status']) { return this.library.stylesheets.find((s) => statuses == null || [statuses || []].flat().includes(s.status)) } getPath() { return `/libraries/${this.libraryId}/stylesheets/${this.id}` } /** Return a current draft or create a new draft bumping up the revision */ getDraft() { const current = this.getCurrent() if (current?.status == 'draft') return current return current.produceNewVersion({ modifiedAt: new Date(), revision: current.revision + 1, status: 'draft' }) } getComparedStylesheet(status: Stylesheet['status']) { const validStylesheets = [this.getCurrent('saved'), this.getCurrent('staged'), this.getCurrent('published')] return validStylesheets.find( (s) => s.status !== status && !s.isUpToDateWith(status) && s.status !== 'draft' // only 'saved', 'staged' and 'published' ) } /** Register a new revision of a stylesheet with given properties based on this stylesheet */ produceNewVersion(props?: Partial) { return this.library.stylesheets.addItem(this.clone(props)) } /** Delete rule from a stylesheet, creates draft */ deleteRule(rule: Style.Rule) { const draft = this.getDraft() if (rule.type === 'collection') { draft.rules.slice().map((s) => { if (s.details.collectionId === rule.details.id) { if (rule.props.type !== 'color' && rule.props.type !== 'breakpoint') { // move things to default collection if exists draft.rules.upsertItem({ ...s, details: { ...s.details, collectionId: `default-${s.type}` } }) } else { // for breakpoints and colors styles are deleted permanently draft.rules.removeItem(s) } } }) } draft.rules.removeItem(rule) return } /** Update or insert a rule in a stylesheet, creates draft */ upsertRule(rule: Style.Rule) { return this.getDraft().rules.upsertItem(rule) } /** Get last revision of a stylesheet that was ever saved */ getLastSaved() { return this.getCurrent(['saved', 'staged', 'published']) } /** Save draft if it has changes over currently saved stylesheet */ async saveDraft() { const draftToSave = this.getChangedDraft() if (draftToSave) { // commit changes from `rules` to `source` in current draft draftToSave.set({ source: draftToSave.getSourceRules(), modifiedAt: new Date() }) // create new saved version of a stylesheet with `saved` status. Revision will not be incremented. draftToSave .produceNewVersion({ status: 'saved' }) .save() } } async stage() { if (!this.isUpToDateWith('staged')) { // revert staged to published if (this.status === 'published') { const currentRevision = this.getCurrent().revision await this.getCurrent('saved').produceNewVersion({ revision: currentRevision + 2 }) return await this.produceNewVersion({ status: 'staged', revision: currentRevision + 1 }) // stage saved/draft } else { return await this.produceNewVersion({ status: 'staged' }).save() } } } async publish() { // on publish do stage as well if (this.status !== 'staged') await this.stage() return await this.produceNewVersion({ status: 'published' }).save() } async revertDraftTo() { const revision = this.getCurrent().revision + 1 return await this.produceNewVersion({ status: 'saved', revision }).save() } /** Get draft if it has changes over currently saved version, will ignore meaningless drafts */ getChangedDraft() { const draft = this.getCurrent() // there's no draft, no need to save if (draft && draft.status != 'draft') return this const saved = this.getLastSaved() if (saved && !saved.isSameContent(draft)) { return draft } } /** Filter out all internal styles that dont need to be saved on backend */ getSourceRules(rules: Style.Rule[] = this.rules.export()) { return Style.Set.fix(rules.concat(Style.Presets.internal)).filter((s) => !s.details.isInternal) } /** Copy stylesheet over to the other library. It will maintain revision numbering if the library already has stylesheet. Note that it's important that stylesheet is first saved and then added to collection, otherwise it will not be able to handle concurrent copyToLibrary calls and assign revisions correctly */ async copyToLibrary(target: LibraryModel, forceStatus: StylesheetModel['status'] = this.status) { const targetCurrentRevision = target.stylesheet?.getCurrent('saved')?.revision ?? -1 return target.stylesheets.add( await target.stylesheets.save({ source: this.source, status: forceStatus, formatVersion: this.formatVersion, revision: targetCurrentRevision + ['draft', 'published', 'staged', 'saved'].indexOf(this.status) }) ) } get source() { return this._source } set source(rules: Style.Rule[]) { const userRules = this.getSourceRules(rules || []) this._source = userRules this._css = undefined this.rules.setItems(userRules.concat(Style.Presets.internal)) } static get version() { return FORMAT_VERSION } static get schema(): SDKModelSchema { return new SDKModelSchema(StylesheetModel, stylesheetSchema) } static getStyleNameWithoutIndex(name: string) { return name.replace(/ \((\d+)\)/gi, '') } static getIndexFromStyleName(name: string) { return name .match(/\((\d+)\)/gi)?.[0] ?.replace('(', '') ?.replace(')', '') } static getStyleMaxIndex(style: Style.Rule, styles: Style.Rule[]) { let max = 0 styles.map((s) => { const sameName = StylesheetModel.getStyleNameWithoutIndex(s.details.title) === StylesheetModel.getStyleNameWithoutIndex(style.details.title) if (s.type === style.type && sameName) { const index = StylesheetModel.getIndexFromStyleName(s.details.title) if (parseInt(index) > max) { max = parseInt(index) } } }) return max } static generateNewComboIds(style: any) { style.props.inlines = style.props.inlines.map((i: Style.Theme.Combo) => ({ ...i, id: nanoid(10) })) style.props.blocks = style.props.blocks.map((i: Style.Theme.Combo) => ({ ...i, id: nanoid(10) })) style.props.texts = style.props.texts.map((i: Style.Theme.Combo) => ({ ...i, id: nanoid(10) })) return style } static statuses = ['published', 'staged', 'saved', 'draft'] static sort(revisions: StylesheetModel[]) { return revisions.sort((a, b) => { return ( Number(b.revision) - Number(a.revision) /** Higher revision first */ || StylesheetModel.statuses.indexOf(a.status) - StylesheetModel.statuses.indexOf(b.status) /** Published first, draft last */ || Number(a.createdAt) - Number(b.createdAt) ) }) } static onCollectionChange(versions: SDKCollection) { return this.sort(versions) } duplicateRule(rule: Style.Rule) { const newRules = StylesheetModel.getDuplicateRule(rule, this.rules) for (const newRule of newRules) this.upsertRule(newRule) return newRules[0] } static getDuplicateRule(rule: Style.Rule, rules: Style.Rule[]) { let copiedStyle = cloneDeep(rule) const newTitle = `${StylesheetModel.getStyleNameWithoutIndex(rule.details.title)} (${ StylesheetModel.getStyleMaxIndex(rule, rules) + 1 })` copiedStyle.details = { ...rule.details, id: nanoid(10), title: newTitle, slug: null } let newStyles: Style.Rule[] = [] for (const s of rules) { if (s.details.elementId !== rule.details.id) continue let copiedCustomStyle: Style.Rule copiedCustomStyle = cloneDeep(s) copiedCustomStyle.details.elementId = copiedStyle.details.id copiedCustomStyle.details.id = nanoid(10) newStyles.push(copiedCustomStyle) copiedStyle.props = Object.keys(copiedStyle.props).reduce((obj: any, prop: any) => { // @ts-ignore const ids = copiedStyle.props[prop] if (!ids.includes(s.details.id)) return { ...obj, [prop]: ids } return { ...obj, [prop]: [...ids.filter((id: string) => id !== s.details.id), copiedCustomStyle.details.id] } }, {}) } if (rule.type === 'theme') { copiedStyle = StylesheetModel.generateNewComboIds(copiedStyle) } return [copiedStyle, ...newStyles] } // fixme: could narrow type here, spent too much time :( static getStyleTabSummary(style: S, form: F): string { const properties = form.extractProperties(style.props) return form.validate(properties) ? 'Custom' : 'None' } getRuleErrors(rule: T, rules: Style.Rule[] = this.rules): Style.RuleError { let errors: any = {} if (rule.details.override) { const parsed = CSS.parse(rule.details.override) if (parsed?.[0]?.[0] == 'error') { return (errors = { ...errors, details: { override: parsed[0][1] } }) } } if (rule.type != 'font') { const otherStyle = Style.Set.findBySlug(rules, rule.type, Style.Rule.getSlug(rule, rules)) if (otherStyle && otherStyle.details.id != rule.details.id) { errors = { ...errors, details: { title: 'Name needs to be unique' } } } } if (rule.type === 'font' ? !rule.props?.familyName : !rule.details.title) { errors = { ...errors, details: { title: 'Name is required' } } } return Object.keys(errors).length === 0 ? null : errors } get Style() { return Style } get ClassList() { return Style.ClassList } get Context() { return Style.Context } get Set() { return Style.Set } } export { StylesheetModel as Sheet }