diff --git a/package.json b/package.json
index 8cbc113..7feb650 100644
--- a/package.json
+++ b/package.json
@@ -60,8 +60,8 @@
"gzip": "3.5 KB"
},
"dist/play-pen.js": {
- "none": "31900 KB",
- "gzip": "5150 KB"
+ "none": "32100 KB",
+ "gzip": "5190 KB"
}
},
"typesVersions": {
diff --git a/src/elements/play-icon/icons/upload-outline.svg b/src/elements/play-icon/icons/upload-outline.svg
new file mode 100644
index 0000000..8691190
--- /dev/null
+++ b/src/elements/play-icon/icons/upload-outline.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/elements/play-icon/play-icon.ts b/src/elements/play-icon/play-icon.ts
index 2b25704..8fd63e0 100644
--- a/src/elements/play-icon/play-icon.ts
+++ b/src/elements/play-icon/play-icon.ts
@@ -35,6 +35,7 @@ import resizeHorizontalOutline from './icons/resize-horizontal-outline.svg'
import restartOutline from './icons/restart-outline.svg'
import shareNewOutline from './icons/share-new-outline.svg'
import unmountOutline from './icons/unmount-outline.svg'
+import uploadOutline from './icons/upload-outline.svg'
export type PlayIconSVG = keyof typeof icons
@@ -62,7 +63,8 @@ const icons = {
'resize-horizontal-outline': resizeHorizontalOutline,
'restart-outline': restartOutline,
'share-ios-outline': unmountOutline,
- 'share-new-outline': shareNewOutline
+ 'share-new-outline': shareNewOutline,
+ 'upload-outline': uploadOutline
}
declare global {
diff --git a/src/elements/play-new-pen-button.ts b/src/elements/play-new-pen-button.ts
index d36cd1b..f639426 100644
--- a/src/elements/play-new-pen-button.ts
+++ b/src/elements/play-new-pen-button.ts
@@ -150,10 +150,12 @@ export class PlayNewPenButton extends LitElement {
+ ) => this.saveProject(ev.detail)}>
+
+
`
}
}
diff --git a/src/elements/play-pen/play-pen.ts b/src/elements/play-pen/play-pen.ts
index 6600315..a5d0713 100644
--- a/src/elements/play-pen/play-pen.ts
+++ b/src/elements/play-pen/play-pen.ts
@@ -58,6 +58,9 @@ import '../play-pen-header.js'
import '../play-preview-controls.js'
import '../play-preview.js'
import '../play-toast.js'
+import type {ProjectStorageClient} from '../../storage/project-storage-client.js'
+import {LocalProjectStorageClient} from '../../storage/local-project-storage-client.js'
+import {ProjectManager} from '../../storage/project-manager.js'
declare global {
interface HTMLElementTagNameMap {
@@ -147,6 +150,9 @@ export class PlayPen extends LitElement {
SVG: svg
}
+ @property()
+ projectStorageClient: ProjectStorageClient = new LocalProjectStorageClient()
+
/** Program executable. */
@state() private _assetsFilesystemType: AssetsFilesystemType = 'virtual'
@state() private _assetsState: AssetsState = emptyAssetsState()
@@ -169,6 +175,7 @@ export class PlayPen extends LitElement {
@query('play-editor') private _editor!: PlayEditor
@query('play-toast') private _toast!: PlayToast
#bundleStore?: BundleStore | undefined
+ _projectManager?: ProjectManager | undefined
readonly #env: VirtualTypeScriptEnvironment = newTSEnv()
@state() _uploaded: Promise = Promise.resolve({})
/** Try to ensure the bundle hostname is unique. See compute-util. */
@@ -206,6 +213,10 @@ export class PlayPen extends LitElement {
// bundle is loaded.
}
+ if (!this._projectManager) {
+ this._projectManager = new ProjectManager(this.projectStorageClient)
+ }
+
let pen
if (this.allowURL) pen = loadPen(location)
if (this.allowStorage) pen ??= loadPen(localStorage)
@@ -235,9 +246,11 @@ export class PlayPen extends LitElement {
>
) => this._assets.onVirtualFileChange(ev.detail)}
@share=${this.#onShare}
+ >
{
+ const el = document.createElement('play-project-button')
+ assert.instanceOf(el, PlayProjectButton)
+})
diff --git a/src/elements/play-project-button.ts b/src/elements/play-project-button.ts
new file mode 100644
index 0000000..2c992ad
--- /dev/null
+++ b/src/elements/play-project-button.ts
@@ -0,0 +1,162 @@
+import {
+ LitElement,
+ css,
+ html,
+ type CSSResultGroup,
+ type TemplateResult
+} from 'lit'
+import {customElement, property} from 'lit/decorators.js'
+
+import {cssReset} from '../utils/css-reset.js'
+import './play-dropdown-menu.js'
+import './play-icon/play-icon.js'
+import './play-list-item.js'
+
+export type SizeOptions = 'small' | 'medium'
+const iconSizes: {[key in SizeOptions]: string} = {
+ small: '16px',
+ medium: '20px'
+}
+
+declare global {
+ interface HTMLElementEventMap {
+ 'edit-src': CustomEvent
+ }
+ interface HTMLElementTagNameMap {
+ 'play-project-button': PlayProjectButton
+ }
+}
+
+@customElement('play-project-button')
+export class PlayProjectButton extends LitElement {
+ static override readonly styles: CSSResultGroup = css`
+ ${cssReset}
+
+ .container {
+ display: flex;
+ flex-direction: row;
+ column-gap: 0;
+ justify-content: space-between;
+ align-items: center;
+ box-shadow: inset 0px 0px 0px var(--border-width)
+ var(--color-secondary-border);
+ border-radius: var(--radius-full);
+ }
+
+ button {
+ font-family: var(--font-family-sans);
+ color: var(--color-secondary-plain);
+ background-color: transparent;
+ border: none;
+ cursor: pointer;
+ }
+
+ :host([size='small']) button {
+ font-size: 12px;
+ font-style: normal;
+ font-weight: 600;
+ line-height: 16px;
+ letter-spacing: -0.1px;
+ }
+
+ :host([size='medium']) button {
+ font-size: 14px;
+ font-style: normal;
+ font-weight: 600;
+ line-height: 20px;
+ letter-spacing: -0.3px;
+ }
+
+ .project-pen {
+ display: flex;
+ flex-direction: row;
+ }
+
+ :host([size='small']) .project-pen {
+ column-gap: 8px;
+ padding-top: 8px;
+ padding-right: 8px;
+ padding-bottom: 8px;
+ padding-left: 12px;
+ border-top-left-radius: 16px;
+ border-bottom-left-radius: 16px;
+ }
+
+ :host([size='medium']) .project-pen {
+ column-gap: 8px;
+ padding-top: 10px;
+ padding-right: 12px;
+ padding-bottom: 10px;
+ padding-left: 16px;
+ border-top-left-radius: 20px;
+ border-bottom-left-radius: 20px;
+ }
+
+ .project-pen:active {
+ background-color: var(--color-secondary-background-active);
+ }
+
+ .project-pen:focus {
+ outline-color: var(--color-brand-background);
+ }
+ `
+ @property({attribute: false}) srcByLabel?: {readonly [key: string]: string}
+ @property() size: SizeOptions = 'medium'
+
+ protected override render(): TemplateResult {
+ return html`
+
+
+
+
+
+ this.dispatchEvent(
+ new CustomEvent('save-project', {
+ bubbles: true,
+ composed: true
+ })
+ )}
+ >
+
+ this.dispatchEvent(
+ new CustomEvent('load-project', {
+ bubbles: true,
+ composed: true
+ })
+ )}
+ >
+
+ this.dispatchEvent(
+ new CustomEvent('open-export-dialog', {
+ bubbles: true,
+ composed: true
+ })
+ )}
+ >
+
+
+
+ `
+ }
+}
diff --git a/src/elements/play-project-load-dialog.test.ts b/src/elements/play-project-load-dialog.test.ts
new file mode 100644
index 0000000..404d2b8
--- /dev/null
+++ b/src/elements/play-project-load-dialog.test.ts
@@ -0,0 +1,7 @@
+import {assert} from '@esm-bundle/chai'
+import {PlayProjectLoadDialog} from './play-project-load-dialog.js'
+
+test('tag is defined', () => {
+ const el = document.createElement('play-project-load-dialog')
+ assert.instanceOf(el, PlayProjectLoadDialog)
+})
diff --git a/src/elements/play-project-load-dialog.ts b/src/elements/play-project-load-dialog.ts
new file mode 100644
index 0000000..ab0ae79
--- /dev/null
+++ b/src/elements/play-project-load-dialog.ts
@@ -0,0 +1,137 @@
+import {
+ css,
+ type CSSResultGroup,
+ html,
+ LitElement,
+ type TemplateResult
+} from 'lit'
+import {customElement, property, query, state} from 'lit/decorators.js'
+import {PlayDialog} from './play-dialog/play-dialog.js'
+import {cssReset} from '../utils/css-reset.js'
+
+import './play-button.js'
+import './play-dialog/play-dialog.js'
+import './play-toast.js'
+import type {PlayProject} from '../storage/project-storage-client.js'
+import {ProjectManager} from '../storage/project-manager.js'
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'play-project-load-dialog': PlayProjectLoadDialog
+ }
+}
+
+@customElement('play-project-load-dialog')
+export class PlayProjectLoadDialog extends LitElement {
+ static override readonly styles: CSSResultGroup = css`
+ ${cssReset}
+
+ p {
+ color: inherit;
+ /* RPL/Body Regular/14-BodyReg */
+ font-family: var(--font-family-sans);
+ font-size: 16px;
+ font-style: normal;
+ font-weight: 400;
+ line-height: 20px;
+ }
+
+ select {
+ width: 100%;
+ font-family: var(--font-family-sans);
+ font-size: 16px;
+ font-style: normal;
+ font-weight: 400;
+ line-height: 20px;
+ letter-spacing: -0.2px;
+ }
+ `
+
+ @property() src: string = ''
+
+ @state() private _loading = false
+ @state() private _projects: PlayProject[] = []
+
+ @query('#project-select')
+ private _projectSelect!: HTMLSelectElement
+
+ @query('play-dialog', true)
+ private _dialog!: PlayDialog
+
+ @property({attribute: 'project-manager', type: ProjectManager})
+ projectManager!: ProjectManager
+
+ async open(): Promise {
+ this._loading = true
+ this._dialog.open()
+ this._projects = await this.projectManager.getProjectList()
+ this._loading = false
+ }
+
+ close(): void {
+ this._dialog.close()
+ }
+
+ async _load(): Promise {
+ const projectId = this._projectSelect.value
+ if (!projectId) return
+ const project = await this.projectManager.loadProject(projectId)
+
+ // TODO: this assumes just one file for Play.
+ const fileContent =
+ (project.files || []).length > 0
+ ? new TextDecoder().decode(project.files[0]?.content)
+ : ''
+
+ this.dispatchEvent(
+ new CustomEvent('edit-src', {
+ detail: fileContent,
+ bubbles: true,
+ composed: true
+ })
+ )
+ this.dispatchEvent(
+ new CustomEvent('edit-name', {
+ detail: project.name,
+ bubbles: true,
+ composed: true
+ })
+ )
+ this.close()
+ }
+
+ protected override render(): TemplateResult {
+ return html`
+
+ ${this._loading
+ ? html`Loading projects...
`
+ : html`Choose a project to load:
+ ${this._projects.length > 0
+ ? html`
+
+
+
+ `
+ : html`No projects available
`}`}
+
+ this._load()}
+ />
+
+
+ `
+ }
+}
diff --git a/src/elements/play-project-save-dialog.test.ts b/src/elements/play-project-save-dialog.test.ts
new file mode 100644
index 0000000..ec06325
--- /dev/null
+++ b/src/elements/play-project-save-dialog.test.ts
@@ -0,0 +1,7 @@
+import {assert} from '@esm-bundle/chai'
+import {PlayProjectSaveDialog} from './play-project-save-dialog.js'
+
+test('tag is defined', () => {
+ const el = document.createElement('play-project-save-dialog')
+ assert.instanceOf(el, PlayProjectSaveDialog)
+})
diff --git a/src/elements/play-project-save-dialog.ts b/src/elements/play-project-save-dialog.ts
new file mode 100644
index 0000000..a28417c
--- /dev/null
+++ b/src/elements/play-project-save-dialog.ts
@@ -0,0 +1,87 @@
+import {
+ css,
+ type CSSResultGroup,
+ html,
+ LitElement,
+ type TemplateResult
+} from 'lit'
+import {customElement, property, query} from 'lit/decorators.js'
+import {PlayDialog} from './play-dialog/play-dialog.js'
+import {cssReset} from '../utils/css-reset.js'
+
+import './play-button.js'
+import './play-dialog/play-dialog.js'
+import './play-toast.js'
+import {Bubble} from '../utils/bubble.js'
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'play-project-save-dialog': PlayProjectSaveDialog
+ }
+}
+
+@customElement('play-project-save-dialog')
+export class PlayProjectSaveDialog extends LitElement {
+ static override readonly styles: CSSResultGroup = css`
+ ${cssReset}
+ `
+
+ @property() src: string = ''
+
+ @query('#project-title', true)
+ private _nameInput!: HTMLInputElement
+
+ @query('#save-button', true)
+ private _saveButton!: HTMLInputElement
+
+ @query('play-dialog', true)
+ private _dialog!: PlayDialog
+
+ open(name: string): void {
+ this._nameInput.value = name || ''
+ this._saveButton.disabled = this._nameInput.value === ''
+ this._dialog.open()
+ this._nameInput.focus()
+ }
+
+ close(): void {
+ this._dialog.close()
+ }
+
+ _save(): void {
+ if (!this._nameInput.value) {
+ return
+ }
+ this._saveButton.disabled = true
+ this.dispatchEvent(
+ Bubble('save-dialog-submit', this._nameInput.value)
+ )
+ }
+
+ protected override render(): TemplateResult {
+ return html`
+
+ {
+ this._saveButton.disabled = !this._nameInput.value
+ if (e.key === 'Enter') {
+ this._save()
+ }
+ }}
+ />
+ this._save()}
+ />
+
+ `
+ }
+}
diff --git a/src/storage/local-project-storage-client.ts b/src/storage/local-project-storage-client.ts
new file mode 100644
index 0000000..de24da8
--- /dev/null
+++ b/src/storage/local-project-storage-client.ts
@@ -0,0 +1,118 @@
+// Implementation of ProjectStorageClient backed by IndexedDB,
+// for storing play project data and files locally in the browser.
+//
+// This is fairly primitive, and doesn't bother with relational data ---
+// it simply stores the entire Project objects, ProjectFiles attached.
+
+import type {
+ PlayProject,
+ ProjectStorageClient
+} from './project-storage-client.js'
+
+const DB_NAME = 'PlayProjectDB'
+const DB_VERSION = 1
+const PROJECT_STORE = 'projects'
+
+function openDB(): Promise {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open(DB_NAME, DB_VERSION)
+
+ request.onupgradeneeded = () => {
+ const db = request.result
+ if (!db.objectStoreNames.contains(PROJECT_STORE)) {
+ db.createObjectStore(PROJECT_STORE, {keyPath: 'id'})
+ }
+ }
+
+ request.onsuccess = () => {
+ resolve(request.result)
+ }
+
+ request.onerror = () => {
+ reject(request.error)
+ }
+ })
+}
+
+/** Browser-local implementation of ProjectStorageClient. */
+export class LocalProjectStorageClient implements ProjectStorageClient {
+ async CreateProject(name: string): Promise {
+ const db = await openDB()
+ const transaction = db.transaction([PROJECT_STORE], 'readwrite')
+ const store = transaction.objectStore(PROJECT_STORE)
+
+ const id = crypto.randomUUID()
+ const project: PlayProject = {
+ id,
+ name,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ authorId: '',
+ files: []
+ }
+
+ return new Promise((resolve, reject) => {
+ const request = store.add(project)
+ request.onsuccess = () => {
+ resolve(project)
+ }
+ request.onerror = () => {
+ reject(request.error)
+ }
+ })
+ }
+
+ async UpdateProject(project: PlayProject): Promise {
+ const db = await openDB()
+ const transaction = db.transaction([PROJECT_STORE], 'readwrite')
+ const store = transaction.objectStore(PROJECT_STORE)
+
+ project.updatedAt = new Date()
+
+ return new Promise((resolve, reject) => {
+ const request = store.put(project)
+ request.onsuccess = () => {
+ resolve()
+ }
+ request.onerror = () => {
+ reject(request.error)
+ }
+ })
+ }
+
+ async GetProject(id: string): Promise {
+ const db = await openDB()
+ const transaction = db.transaction([PROJECT_STORE], 'readonly')
+ const store = transaction.objectStore(PROJECT_STORE)
+
+ return new Promise((resolve, reject) => {
+ const request = store.get(id)
+ request.onsuccess = () => {
+ if (request.result) {
+ resolve(request.result)
+ } else {
+ reject(new Error('Project not found'))
+ }
+ }
+ request.onerror = () => {
+ reject(request.error)
+ }
+ })
+ }
+
+ async ListProjects(): Promise {
+ const db = await openDB()
+ const transaction = db.transaction([PROJECT_STORE], 'readonly')
+ const store = transaction.objectStore(PROJECT_STORE)
+
+ return new Promise((resolve, reject) => {
+ const request = store.getAll()
+ request.onsuccess = () => {
+ resolve(request.result)
+ }
+ request.onerror = () => {
+ reject(request.error)
+ }
+ })
+ }
+}
diff --git a/src/storage/project-manager.ts b/src/storage/project-manager.ts
new file mode 100644
index 0000000..daf39e3
--- /dev/null
+++ b/src/storage/project-manager.ts
@@ -0,0 +1,75 @@
+import type {
+ PlayProject,
+ ProjectStorageClient
+} from './project-storage-client.js'
+
+const SESSION_PROJECT_ID = 'SESSION_PROJECT_ID'
+
+/**
+ * Operator for saving and loading projects. Handles logic for when and how to save.
+ *
+ * The underlying storage mechanism is abstracted away by the injected storage client.
+ */
+export class ProjectManager {
+ private projectStorageClient: ProjectStorageClient
+ private currentProject: PlayProject | undefined
+
+ constructor(projectStorageClient: ProjectStorageClient) {
+ this.projectStorageClient = projectStorageClient
+
+ const restoredProjectStr =
+ globalThis.sessionStorage.getItem(SESSION_PROJECT_ID)
+ if (restoredProjectStr) {
+ try {
+ this.currentProject = JSON.parse(restoredProjectStr)
+ } catch (e) {
+ // fall-through --- invalid data, just ignore it.
+ }
+ }
+ }
+
+ getCurrentProject(): PlayProject | undefined {
+ return this.currentProject
+ }
+
+ async saveProject(name: string, src: string): Promise {
+ let project = this.getCurrentProject()
+ if (project === undefined) {
+ project = await this.projectStorageClient.CreateProject(name)
+ }
+
+ project.files = [{name: 'main.tsx', content: new TextEncoder().encode(src)}]
+ project.name = name
+ project.updatedAt = new Date()
+ await this.projectStorageClient.UpdateProject(project)
+
+ // Store the project in memory and in sessionStorage
+ this.setCurrentProject(project)
+ }
+
+ async getProjectList(): Promise {
+ return this.projectStorageClient.ListProjects()
+ }
+
+ async loadProject(id: string): Promise {
+ const project = await this.projectStorageClient.GetProject(id)
+ this.setCurrentProject(project)
+ return project
+ }
+
+ clearCurrentProject(): void {
+ this.setCurrentProject(undefined)
+ }
+
+ private setCurrentProject(project: PlayProject | undefined): void {
+ this.currentProject = project
+ if (project) {
+ globalThis.sessionStorage.setItem(
+ SESSION_PROJECT_ID,
+ JSON.stringify(project)
+ )
+ } else {
+ globalThis.sessionStorage.removeItem(SESSION_PROJECT_ID)
+ }
+ }
+}
diff --git a/src/storage/project-storage-client.ts b/src/storage/project-storage-client.ts
new file mode 100644
index 0000000..0bc1713
--- /dev/null
+++ b/src/storage/project-storage-client.ts
@@ -0,0 +1,35 @@
+/**
+ * Interface for a client that can store and retrieve PlayProjects.
+ *
+ * This can be injected into play-pen to provide a different implementation.
+ */
+export interface ProjectStorageClient {
+ CreateProject(name: string): Promise
+ UpdateProject(project: PlayProject): Promise
+ GetProject(id: string): Promise
+ ListProjects(): Promise
+}
+
+export interface PlayProject {
+ /** readonly */
+ id?: string | undefined
+ name: string
+ /** readonly */
+ createdAt?: Date | undefined
+ /** readonly */
+ updatedAt?: Date | undefined
+ /** t2_ id of the user who created a note */
+ authorId?: string | undefined
+ files: PlayProjectFile[]
+}
+
+export interface PlayProjectFile {
+ /** readonly */
+ id?: string | undefined
+ name: string
+ content: Uint8Array
+ /** readonly */
+ createdAt?: Date | undefined
+ /** readonly */
+ updatedAt?: Date | undefined
+}