diff --git a/.lune/build.luau b/.lune/build.luau index 2f827397..1e356620 100644 --- a/.lune/build.luau +++ b/.lune/build.luau @@ -1,9 +1,10 @@ +local process = require("@lune/process") + local clean = require("./lib/clean") local compile = require("./lib/compile") local constants = require("./lib/constants") local getPluginsPath = require("./lib/getPluginsPath") local parseArgs = require("./lib/parseArgs") -local process = require("@lune/process") local run = require("./lib/run") local watch = require("./lib/watcher/watch") @@ -29,6 +30,7 @@ if args.watch then filePatterns = { "src/.*%.luau", "example/.*%.luau", + "Packages/.*%.luau", }, onChanged = build, }) diff --git a/.lune/lib/parseArgs.luau b/.lune/lib/parseArgs.luau index a506f990..522f8f6c 100644 --- a/.lune/lib/parseArgs.luau +++ b/.lune/lib/parseArgs.luau @@ -2,7 +2,7 @@ local FLAG_PATTERN = "%-%-(%w+)" local FLAG_ALL_IN_ONE_PATTERN = `{FLAG_PATTERN}=(%w+)` local function parseArgs(args: { string }) - local parsedArgs: { [string]: string | boolean | number } = {} + local parsedArgs: { [string]: string | boolean | number | nil } = {} local skipNextToken = false diff --git a/.lune/wally-install.luau b/.lune/wally-install.luau index 2472434c..4f5ae971 100644 --- a/.lune/wally-install.luau +++ b/.lune/wally-install.luau @@ -1,7 +1,24 @@ +local process = require("@lune/process") + local run = require("./lib/run") +local function installPackageFromDisk(packageName: string, packagePath: string, runOptions: { [string]: any }?) + local homePath = process.env.HOME + assert(homePath, "no $HOME env var") + + local absPackagePath = run("realpath", { packagePath }) + + run("rm", { `Packages/{packageName}.lua` }, runOptions) + run("ln", { "-s", `{absPackagePath}/dist`, `Packages/{packageName}` }, runOptions) +end + do run("wally", { "install" }) + + installPackageFromDisk("Storyteller", "../storyteller") + -- ModuleLoader is a dependency of Storyteller, make sure to install both. + installPackageFromDisk("ModuleLoader", "../module-loader") + run("rojo", { "sourcemap", "build.project.json", "-o", "sourcemap.json" }) run("wally-package-types", { "--sourcemap", "sourcemap.json", "Packages" }) end @@ -10,6 +27,11 @@ do run("wally", { "install" }, { cwd = "code-samples", }) + + installPackageFromDisk("Storyteller", "../storyteller", { + cwd = "code-samples", + }) + run("rojo", { "sourcemap", "default.project.json", "-o", "sourcemap.json" }, { cwd = "code-samples", }) diff --git a/img/Alert.png b/img/Alert.png new file mode 100644 index 00000000..23743d46 Binary files /dev/null and b/img/Alert.png differ diff --git a/img/Star.png b/img/Star.png new file mode 100644 index 00000000..e1bba5da Binary files /dev/null and b/img/Star.png differ diff --git a/img/StarFilled.png b/img/StarFilled.png new file mode 100644 index 00000000..fd0fbb12 Binary files /dev/null and b/img/StarFilled.png differ diff --git a/src/Common/CodeBlock.luau b/src/Common/CodeBlock.luau new file mode 100644 index 00000000..63fd5263 --- /dev/null +++ b/src/Common/CodeBlock.luau @@ -0,0 +1,94 @@ +local Highlighter = require("@pkg/Highlighter") +local React = require("@pkg/React") +local Sift = require("@pkg/Sift") + +local SelectableTextLabel = require("@root/Forms/SelectableTextLabel") +local nextLayoutOrder = require("@root/Common/nextLayoutOrder") +local useTheme = require("@root/Common/useTheme") + +local useMemo = React.useMemo + +local function getLineNumbers(str: string): string + return Sift.List.reduce(str:split("\n"), function(accumulator, _item, index) + return if index == 1 then tostring(index) else `{accumulator}\n{index}` + end, "") +end + +export type Props = { + source: string, + sourceColor: Color3?, + layoutOrder: number?, +} + +local function CodeBlock(props: Props) + local theme = useTheme() + + local sourceColor = useMemo(function() + return if props.sourceColor then props.sourceColor else theme.text + end, { props.sourceColor }) + + local source = useMemo(function() + if props.sourceColor then + return props.source + else + return table.concat( + Highlighter.buildRichTextLines({ + src = props.source, + }), + "\n" + ) + end + end, { props.source }) + + return React.createElement("Frame", { + AutomaticSize = Enum.AutomaticSize.XY, + BackgroundTransparency = 0.5, + BorderSizePixel = 0, + BackgroundColor3 = theme.sidebar, + LayoutOrder = props.layoutOrder, + }, { + Layout = React.createElement("UIListLayout", { + SortOrder = Enum.SortOrder.LayoutOrder, + FillDirection = Enum.FillDirection.Horizontal, + Padding = theme.padding, + }), + + BorderRadius = React.createElement("UICorner", { + CornerRadius = theme.corner, + }), + + Padding = React.createElement("UIPadding", { + PaddingTop = theme.padding, + PaddingRight = theme.padding, + PaddingBottom = theme.padding, + PaddingLeft = theme.padding, + }), + + LineNumbers = React.createElement("TextLabel", { + LayoutOrder = nextLayoutOrder(), + AutomaticSize = Enum.AutomaticSize.XY, + Text = getLineNumbers(source), + TextSize = theme.textSize, + LineHeight = 1, + BackgroundTransparency = 1, + Font = Enum.Font.RobotoMono, + TextColor3 = theme.textFaded, + TextXAlignment = Enum.TextXAlignment.Right, + }), + + SourceCode = React.createElement(SelectableTextLabel, { + RichText = true, + LayoutOrder = nextLayoutOrder(), + Size = UDim2.fromScale(1, 0), + AutomaticSize = Enum.AutomaticSize.Y, + Text = source, + TextColor3 = sourceColor, + TextSize = theme.textSize, + TextWrapped = false, + LineHeight = 1, + Font = Enum.Font.RobotoMono, + }), + }) +end + +return CodeBlock diff --git a/src/Common/ContextProviders.luau b/src/Common/ContextProviders.luau index 430571ed..90115356 100644 --- a/src/Common/ContextProviders.luau +++ b/src/Common/ContextProviders.luau @@ -2,6 +2,7 @@ local React = require("@pkg/React") local TreeView = require("@root/TreeView") local ContextStack = require("@root/Common/ContextStack") +local LocalStorageContext = require("@root/Plugin/LocalStorageContext") local NavigationContext = require("@root/Navigation/NavigationContext") local PluginContext = require("@root/Plugin/PluginContext") local SettingsContext = require("@root/UserSettings/SettingsContext") @@ -20,6 +21,9 @@ local function ContextProviders(props: Props) React.createElement(NavigationContext.Provider, { defaultScreen = "Home", }), + React.createElement(LocalStorageContext.Provider, { + storageKey = "FlipbookInternal", + }), React.createElement(SettingsContext.Provider), React.createElement(TreeView.TreeViewProvider), }, diff --git a/src/Common/getInstanceFromFullName.luau b/src/Common/getInstanceFromFullName.luau deleted file mode 100644 index e52e9cbb..00000000 --- a/src/Common/getInstanceFromFullName.luau +++ /dev/null @@ -1,64 +0,0 @@ ---[[ - Gets an instance based off the result of GetFullName(). - - This is used in conjunction with debug.info() to locate the calling script. - - Returns nil if the instance is outside the DataModel. -]] - -local Sift = require("@pkg/Sift") - -local tryGetService = require("@root/Permissions/tryGetService") - -local PATH_SEPERATOR = "." - -local function getInstanceFromFullName(fullName: string): Instance? - local parts = fullName:split(PATH_SEPERATOR) - local serviceName = table.remove(parts, 1) - - if serviceName then - -- This function only works for instances in the DataModel. As such, the - -- first part of the path will always be a service, so if we can't find - -- one we exit out and return nil - local current = tryGetService(serviceName) - - if current then - while #parts > 0 do - -- Keep around a copy of the `parts` array. We are going to concat this - -- into new paths, and incrementally remove from the right to narrow - -- down the file path. - local tempParts = Sift.Array.copy(parts) - - -- The result of GetFullName() uses dots to separate paths, but we also - -- use dots in our file names (e.g. with spec and story files). As such, - -- this block will look ahead to see if multiple parts are actually a - -- single filename. - for _ = 1, #tempParts do - local name = table.concat(tempParts, PATH_SEPERATOR) - local found = current:FindFirstChild(name) - - if found then - current = found - parts = Sift.List.shift(parts, #name:split(PATH_SEPERATOR)) - break - else - if #tempParts == 1 then - -- This fixes a crash when searching for paths that - -- no longer exist - return nil - else - -- Reduce from the right until we find the next instance - tempParts = Sift.List.pop(tempParts) - end - end - end - end - - return current - end - end - - return nil -end - -return getInstanceFromFullName diff --git a/src/Common/getInstanceFromPath.luau b/src/Common/getInstanceFromPath.luau new file mode 100644 index 00000000..a28c0d7d --- /dev/null +++ b/src/Common/getInstanceFromPath.luau @@ -0,0 +1,44 @@ +--[[ + Gets an instance based off the result of getInstancePath. + + The reason we use this over GetFullName is because it uses a dot (.) as the + path separator which makes it difficult to disambiguate instances stories + and test files (Foo.story and Foo.spec, respectively) + + Returns `nil` if the instance is outside the DataModel or otherwise cannot + be found. +]] + +local tryGetService = require("@root/RobloxInternal/tryGetService") + +local PATH_SEPERATOR = "/" + +local function getInstanceFromPath(path: string): Instance? + local parts = path:split(PATH_SEPERATOR) + local serviceName = parts[1] + + if serviceName then + -- This function only works for instances in the DataModel. As such, the + -- first part of the path will always be a service, so if we can't find + -- one we exit out and return nil + local current = tryGetService(serviceName) + + if current then + for i = 2, #parts do + local found = current:FindFirstChild(parts[i]) + + if found then + current = found + else + return nil + end + end + + return current + end + end + + return nil +end + +return getInstanceFromPath diff --git a/src/Common/getInstanceFromFullName.spec.luau b/src/Common/getInstanceFromPath.spec.luau similarity index 62% rename from src/Common/getInstanceFromFullName.spec.luau rename to src/Common/getInstanceFromPath.spec.luau index d110cbda..a343e4a4 100644 --- a/src/Common/getInstanceFromFullName.spec.luau +++ b/src/Common/getInstanceFromPath.spec.luau @@ -3,7 +3,8 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") local JestGlobals = require("@pkg/JestGlobals") local newFolder = require("@root/Testing/newFolder") -local getInstanceFromFullName = require("./getInstanceFromFullName") +local getInstanceFromPath = require("./getInstanceFromPath") +local getInstancePath = require("./getInstancePath") local expect = JestGlobals.expect local test = JestGlobals.test @@ -18,7 +19,7 @@ afterEach(function() end) test("gets services", function() - local path = getInstanceFromFullName("ReplicatedStorage") + local path = getInstanceFromPath("ReplicatedStorage") expect(path).toBe(ReplicatedStorage) end) @@ -32,8 +33,7 @@ test("works on nested instances", function() }) folder.Parent = ReplicatedStorage - local path = getInstanceFromFullName(module:GetFullName()) - expect(path).toBe(module) + expect(getInstanceFromPath(getInstancePath(module))).toBe(module) end) test("works with spec files", function() @@ -46,7 +46,7 @@ test("works with spec files", function() }) folder.Parent = ReplicatedStorage - local path = getInstanceFromFullName(module:GetFullName()) + local path = getInstanceFromPath(module:GetFullName()) expect(path).toBe(module) end) @@ -61,18 +61,17 @@ test("finds spec files BEFORE the module it is associated with", function() }) folder.Parent = ReplicatedStorage - local path = getInstanceFromFullName(module:GetFullName()) - expect(path).toBe(module) + expect(getInstanceFromPath(getInstancePath(module))).toBe(module) end) test("returns nil if the first part of the path is not a service", function() - expect(getInstanceFromFullName("Part")).toBeUndefined() + expect(getInstanceFromPath("Part")).toBeUndefined() end) test("returns nil if the path does not exist", function() - expect(getInstanceFromFullName("Foo.story")).toBeUndefined() - expect(getInstanceFromFullName("Path.To.Foo.story")).toBeUndefined() - expect(getInstanceFromFullName("ReplicatedStorage.Foo.Bar.Baz")).toBeUndefined() - expect(getInstanceFromFullName("ReplicatedStorage.Sample.story")).toBeUndefined() - expect(getInstanceFromFullName("ReplicatedStorage.Sample.spec")).toBeUndefined() + expect(getInstanceFromPath("Foo.story")).toBeUndefined() + expect(getInstanceFromPath("Path/To/Foo.story")).toBeUndefined() + expect(getInstanceFromPath("ReplicatedStorage/Foo/Bar/Baz")).toBeUndefined() + expect(getInstanceFromPath("ReplicatedStorage/Sample.story")).toBeUndefined() + expect(getInstanceFromPath("ReplicatedStorage/Sample.spec")).toBeUndefined() end) diff --git a/src/Common/getInstancePath.luau b/src/Common/getInstancePath.luau new file mode 100644 index 00000000..082df3e6 --- /dev/null +++ b/src/Common/getInstancePath.luau @@ -0,0 +1,18 @@ +local PATH_SEPARATOR = "/" + +local function getInstancePath(instance: Instance, pathSeparator: string?): string + pathSeparator = if pathSeparator then pathSeparator else PATH_SEPARATOR + assert(pathSeparator, "Luau") + + local path = {} + local current = instance + + while current and current.Parent ~= nil do + table.insert(path, 1, current.Name) + current = current.Parent + end + + return table.concat(path, "/") +end + +return getInstancePath diff --git a/src/Common/getInstancePath.spec.luau b/src/Common/getInstancePath.spec.luau new file mode 100644 index 00000000..1ee401ba --- /dev/null +++ b/src/Common/getInstancePath.spec.luau @@ -0,0 +1,48 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local JestGlobals = require("@pkg/JestGlobals") +local newFolder = require("@root/Testing/newFolder") + +local getInstancePath = require("./getInstancePath") + +local expect = JestGlobals.expect +local test = JestGlobals.test +local afterEach = JestGlobals.afterEach + +local folder: Folder + +afterEach(function() + if folder then + folder:Destroy() + end +end) + +test("services are treated as the root", function() + expect(getInstancePath(ReplicatedStorage)).toBe("ReplicatedStorage") +end) + +test("works on nested instances", function() + local module = Instance.new("ModuleScript") + + folder = newFolder({ + foo = newFolder({ + bar = module, + }), + }) + folder.Parent = ReplicatedStorage + + expect(getInstancePath(module)).toBe("ReplicatedStorage/Folder/foo/bar") +end) + +test("works with spec files", function() + local module = Instance.new("ModuleScript") + + folder = newFolder({ + foo = newFolder({ + ["bar.spec"] = module, + }), + }) + folder.Parent = ReplicatedStorage + + expect(getInstancePath(module)).toBe("ReplicatedStorage/Folder/foo/bar.spec") +end) diff --git a/src/Navigation/Screen.luau b/src/Navigation/Screen.luau index ac3909dd..e792b1ae 100644 --- a/src/Navigation/Screen.luau +++ b/src/Navigation/Screen.luau @@ -1,4 +1,3 @@ -local ModuleLoader = require("@pkg/ModuleLoader") local React = require("@pkg/React") local Storyteller = require("@pkg/Storyteller") @@ -7,14 +6,13 @@ local NavigationContext = require("@root/Navigation/NavigationContext") local NoStorySelected = require("@root/Storybook/NoStorySelected") local SettingsView = require("@root/UserSettings/SettingsView") local StoryCanvas = require("@root/Storybook/StoryCanvas") +local StorybookError = require("@root/Storybook/StorybookError") local useMemo = React.useMemo -type ModuleLoader = ModuleLoader.ModuleLoader type LoadedStorybook = Storyteller.LoadedStorybook export type Props = { - loader: ModuleLoader, story: ModuleScript?, storybook: LoadedStorybook?, } @@ -27,10 +25,13 @@ local function Screen(props: Props) if currentScreen == "Home" then if props.story and props.storybook then return React.createElement(StoryCanvas, { - loader = props.loader, story = props.story, storybook = props.storybook, }) + elseif props.unavailableStorybook then + return React.createElement(StorybookError, { + unavailableStorybook = props.unavailableStorybook, + }) else return React.createElement(NoStorySelected) end diff --git a/src/Panels/Sidebar.luau b/src/Panels/Sidebar.luau index f58b76b3..b622e5d5 100644 --- a/src/Panels/Sidebar.luau +++ b/src/Panels/Sidebar.luau @@ -1,30 +1,36 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + local React = require("@pkg/React") local Storyteller = require("@pkg/Storyteller") local Branding = require("@root/Common/Branding") +local Button = require("@root/Forms/Button") local ScrollingFrame = require("@root/Common/ScrollingFrame") local Searchbar = require("@root/Forms/Searchbar") local StorybookTreeView = require("@root/Storybook/StorybookTreeView") +local createOnboardingStorybook = require("@root/Storybook/createOnboardingStorybook") +local getMostLikelyProjectSources = require("@root/RobloxInternal/getMostLikelyProjectSources") +local nextLayoutOrder = require("@root/Common/nextLayoutOrder") local useTheme = require("@root/Common/useTheme") type LoadedStorybook = Storyteller.LoadedStorybook +type UnavailableStorybook = Storyteller.UnavailableStorybook local e = React.createElement type Props = { layoutOrder: number?, onStoryChanged: (storyModule: ModuleScript?, storybook: LoadedStorybook?) -> (), - storybooks: { LoadedStorybook }, + onShowErrorPage: (unavailableStorybook: UnavailableStorybook) -> (), + storybooks: { + avialable: { LoadedStorybook }, + unavailable: { UnavailableStorybook }, + }, } local function Sidebar(props: Props) local theme = useTheme() - local headerHeight, setHeaderHeight = React.useState(0) - local onHeaderSizeChanged = React.useCallback(function(rbx: Frame) - setHeaderHeight(rbx.AbsoluteSize.Y) - end, { setHeaderHeight }) - local search: string?, setSearch = React.useState(nil :: string?) local onSearchChanged = React.useCallback(function(value: string) if value == "" then @@ -42,6 +48,7 @@ local function Sidebar(props: Props) }, { UIListLayout = e("UIListLayout", { Padding = theme.padding, + VerticalFlex = Enum.UIFlexAlignment.Fill, SortOrder = Enum.SortOrder.LayoutOrder, }), @@ -55,33 +62,56 @@ local function Sidebar(props: Props) Header = e("Frame", { AutomaticSize = Enum.AutomaticSize.Y, BackgroundTransparency = 1, - LayoutOrder = 0, + LayoutOrder = nextLayoutOrder(), Size = UDim2.fromScale(1, 0), - [React.Change.AbsoluteSize] = onHeaderSizeChanged, }, { + FlexItem = React.createElement("UIFlexItem", { + FlexMode = Enum.UIFlexMode.Grow, + }), + UIListLayout = e("UIListLayout", { Padding = theme.paddingLarge, SortOrder = Enum.SortOrder.LayoutOrder, }), Branding = e(Branding, { - layoutOrder = 0, + layoutOrder = nextLayoutOrder(), }), Searchbar = e(Searchbar, { - layoutOrder = 1, + layoutOrder = nextLayoutOrder(), onSearchChanged = onSearchChanged, }), + + --if #props.storybooks.available == 0 + CreateStorybook = React.createElement(Button, { + layoutOrder = nextLayoutOrder(), + text = "Create Storybook", + onClick = function() + local source = getMostLikelyProjectSources()[1] + + if source then + createOnboardingStorybook(source.Name, source) + else + createOnboardingStorybook(string.gsub(game.Name, "%.", "_"), ReplicatedStorage) + end + end, + }), }), ScrollingFrame = e(ScrollingFrame, { - LayoutOrder = 1, - Size = UDim2.fromScale(1, 1) - UDim2.fromOffset(0, headerHeight), + LayoutOrder = nextLayoutOrder(), + Size = UDim2.fromScale(1, 1), }, { + FlexItem = React.createElement("UIFlexItem", { + FlexMode = Enum.UIFlexMode.Shrink, + }), + StorybookTreeView = e(StorybookTreeView, { searchTerm = search, storybooks = props.storybooks, onStoryChanged = props.onStoryChanged, + onShowErrorPage = props.onShowErrorPage, }), }), }) diff --git a/src/Permissions/tryGetService.luau b/src/Permissions/tryGetService.luau deleted file mode 100644 index 565fa95e..00000000 --- a/src/Permissions/tryGetService.luau +++ /dev/null @@ -1,21 +0,0 @@ -local function tryGetService(serviceName: string): Instance? - local service - - pcall(function() - service = game:GetService(serviceName) - end) - - if service then - return service - end - - -- Some services cannot be retrieved by GetService but still exist in the DM - -- and can be retrieved by name. - pcall(function() - service = game:FindFirstChild(serviceName) - end) - - return service -end - -return tryGetService diff --git a/src/Plugin/LocalStorageContext.luau b/src/Plugin/LocalStorageContext.luau new file mode 100644 index 00000000..0f705cb6 --- /dev/null +++ b/src/Plugin/LocalStorageContext.luau @@ -0,0 +1,91 @@ +local HttpService = game:GetService("HttpService") + +local React = require("@pkg/React") +local Sift = require("@pkg/Sift") + +local PluginContext = require("@root/Plugin/PluginContext") + +local useCallback = React.useCallback +local useContext = React.useContext +local useState = React.useState + +export type LocalStorage = { + [string]: unknown, +} + +export type LocalStorageContext = { + get: (key: string) -> unknown, + set: (key: string, value: unknown) -> (), +} + +local LocalStorageContext = React.createContext(nil :: LocalStorageContext?) + +export type Props = { + storageKey: string, + children: React.Node, +} + +local function LocalStorageProvider(props: Props) + local plugin = useContext(PluginContext.Context) + + local loadFromDisk = useCallback(function(): LocalStorage + local data = plugin:GetSetting(props.storageKey) + if data then + local json = HttpService:JSONDecode(data) + if json then + return json + end + end + return {} + end, { plugin, props.storageKey } :: { unknown }) + + local saveToDisk = useCallback(function(newStorage: { [any]: any }) + local data = HttpService:JSONEncode(newStorage) + if data then + plugin:SetSetting(props.storageKey, data) + end + end, { plugin, props.storageKey } :: { unknown }) + + local storage, setStorage = useState(function() + return loadFromDisk() + end) + + local get = useCallback(function(key: string) + return storage[key] + end, { storage }) + + local set = useCallback(function(key: string, value: unknown?) + setStorage(function(prev) + local new = Sift.Dictionary.join(prev, { + [key] = if value == nil then Sift.None else value, + }) + + saveToDisk(new) + + return new + end) + end, { storage }) + + local context: LocalStorageContext = { + get = get, + set = set, + } + + return React.createElement(LocalStorageContext.Provider, { + value = context, + }, props.children) +end + +local function useLocalStorage(): LocalStorageContext + local context = useContext(LocalStorageContext) + if not context then + local contextName = script.Name + error(`failed to use {contextName}, is \`{contextName}.Provider\` defined in the React hierarchy?`) + end + return context +end + +return { + Provider = LocalStorageProvider, + use = useLocalStorage, +} diff --git a/src/Plugin/PluginApp.luau b/src/Plugin/PluginApp.luau index 53877344..db4bb876 100644 --- a/src/Plugin/PluginApp.luau +++ b/src/Plugin/PluginApp.luau @@ -1,4 +1,3 @@ -local ModuleLoader = require("@pkg/ModuleLoader") local React = require("@pkg/React") local Storyteller = require("@pkg/Storyteller") @@ -15,17 +14,15 @@ local useTheme = require("@root/Common/useTheme") local TOPBAR_HEIGHT_PX = 32 type LoadedStorybook = Storyteller.LoadedStorybook +type UnavailableStorybook = Storyteller.UnavailableStorybook -export type Props = { - loader: ModuleLoader.ModuleLoader, -} - -local function App(props: Props) +local function App() local theme = useTheme() local settingsContext = SettingsContext.use() - local storybooks = Storyteller.useStorybooks(game, props.loader) + local storybooks = Storyteller.useStorybooks(game) local storyModule: ModuleScript?, setStoryModule = React.useState(nil :: ModuleScript?) local storybook, setStorybook = React.useState(nil :: LoadedStorybook?) + local unavailableStorybook, setUnavailableStorybook = React.useState(nil :: LoadedStorybook?) local initialSidebarWidth = settingsContext.getSetting("sidebarWidth") local sidebarWidth, setSidebarWidth = React.useState(initialSidebarWidth) local navigation = NavigationContext.use() @@ -33,13 +30,17 @@ local function App(props: Props) local onStoryChanged = React.useCallback(function(newStoryModule: ModuleScript?, newStorybook: LoadedStorybook?) navigation.navigateTo("Home") - setStoryModule(function(prev: ModuleScript?) - return if prev ~= newStoryModule then newStoryModule else nil - end) - + setUnavailableStorybook(nil) + setStoryModule(newStoryModule) setStorybook(newStorybook) end, { navigation.navigateTo } :: { unknown }) + local onShowErrorPage = React.useCallback(function(newUnavailableStorybook: UnavailableStorybook) + setStoryModule(nil) + setStorybook(nil) + setUnavailableStorybook(newUnavailableStorybook) + end, {}) + local onSidebarResized = React.useCallback(function(newSize: Vector2) setSidebarWidth(newSize.X) end, {}) @@ -67,7 +68,8 @@ local function App(props: Props) }, { Sidebar = React.createElement(Sidebar, { onStoryChanged = onStoryChanged, - storybooks = storybooks.available, + onShowErrorPage = onShowErrorPage, + storybooks = storybooks, }), }), @@ -91,9 +93,9 @@ local function App(props: Props) BackgroundTransparency = 1, }, { Screen = React.createElement(Screen, { - loader = props.loader, story = storyModule, storybook = storybook, + unavailableStorybook = unavailableStorybook, }), }), }), diff --git a/src/Plugin/PluginApp.story.luau b/src/Plugin/PluginApp.story.luau index 858937b7..e62cede1 100644 --- a/src/Plugin/PluginApp.story.luau +++ b/src/Plugin/PluginApp.story.luau @@ -1,4 +1,3 @@ -local ModuleLoader = require("@pkg/ModuleLoader") local React = require("@pkg/React") local ContextProviders = require("@root/Common/ContextProviders") @@ -11,9 +10,6 @@ return { story = React.createElement(ContextProviders, { plugin = MockPlugin.new() :: any, }, { - PluginApp = React.createElement(PluginApp, { - loader = ModuleLoader.new(), - plugin = plugin, - }), + PluginApp = React.createElement(PluginApp), }), } diff --git a/src/Plugin/createFlipbookPlugin.luau b/src/Plugin/createFlipbookPlugin.luau new file mode 100644 index 00000000..2d8cd79f --- /dev/null +++ b/src/Plugin/createFlipbookPlugin.luau @@ -0,0 +1,81 @@ +local RunService = game:GetService("RunService") + +if RunService:IsRunning() or not RunService:IsEdit() then + return +end + +local React = require("@pkg/React") +local ReactRoblox = require("@pkg/ReactRoblox") + +local ContextProviders = require("@root/Common/ContextProviders") +local PluginApp = require("@root/Plugin/PluginApp") + +local function createFlipbookPlugin( + plugin: Plugin, + widget: DockWidgetPluginGui, + button: PluginToolbarButton +): { + mount: () -> (), + unmount: () -> (), +} + local connections: { RBXScriptConnection } = {} + local root = ReactRoblox.createRoot(widget) + + local app = React.createElement(ContextProviders, { + plugin = plugin, + }, { + PluginApp = React.createElement(PluginApp), + }) + + local function unmount() + root:unmount() + end + + local function mount() + root:render(app) + end + + table.insert( + connections, + button.Click:Connect(function() + widget.Enabled = not widget.Enabled + end) + ) + + table.insert( + connections, + widget:GetPropertyChangedSignal("Enabled"):Connect(function() + button:SetActive(widget.Enabled) + end) + ) + + table.insert( + connections, + widget:GetPropertyChangedSignal("Enabled"):Connect(function() + if widget.Enabled then + root:render(app) + else + unmount() + end + end) + ) + + if widget.Enabled then + mount() + end + + local function destroy() + unmount() + for _, connection in connections do + connection:Disconnect() + end + end + + return { + mount = mount, + unmount = unmount, + destroy = destroy, + } +end + +return createFlipbookPlugin diff --git a/src/Plugin/createToggleButton.luau b/src/Plugin/createToggleButton.luau deleted file mode 100644 index 5519493a..00000000 --- a/src/Plugin/createToggleButton.luau +++ /dev/null @@ -1,26 +0,0 @@ ---[[ - Creates the button to toggle the plugin widget. - - This function also sets up some events to toggle the widget when the button - is clicked, and to sync up the button's "active" state with the widget. - - @return () -> () -- Returns a callback for disconnecting button events -]] -local function createToggleButton(toolbar: PluginToolbar, widget: DockWidgetPluginGui) - local button = toolbar:CreateButton(widget.Name, "Open story view", "rbxassetid://10277153751") - - local click = button.Click:Connect(function() - widget.Enabled = not widget.Enabled - end) - - local enabled = widget:GetPropertyChangedSignal("Enabled"):Connect(function() - button:SetActive(widget.Enabled) - end) - - return function() - click:Disconnect() - enabled:Disconnect() - end -end - -return createToggleButton diff --git a/src/Plugin/createWidget.luau b/src/Plugin/createWidget.luau deleted file mode 100644 index d4e8e1a6..00000000 --- a/src/Plugin/createWidget.luau +++ /dev/null @@ -1,12 +0,0 @@ -local function createWidget(plugin: Plugin, name: string): DockWidgetPluginGui - local info = DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Top, true) - - local widget = plugin:CreateDockWidgetPluginGui(name, info) - widget.Name = name - widget.Title = name - widget.ZIndexBehavior = Enum.ZIndexBehavior.Sibling - - return widget -end - -return createWidget diff --git a/src/RobloxInternal/canAccess.luau b/src/RobloxInternal/canAccess.luau new file mode 100644 index 00000000..75f98107 --- /dev/null +++ b/src/RobloxInternal/canAccess.luau @@ -0,0 +1,11 @@ +local function canAccess(instance: Instance) + local success = pcall(function() + -- Attempt any use of the instance. If it throws an error, we assume + -- that the current script context cannot access it + return instance.Name + end) + + return success +end + +return canAccess diff --git a/src/RobloxInternal/getInternalSyncItems.luau b/src/RobloxInternal/getInternalSyncItems.luau new file mode 100644 index 00000000..a93e1099 --- /dev/null +++ b/src/RobloxInternal/getInternalSyncItems.luau @@ -0,0 +1,22 @@ +local tryGetService = require("@root/RobloxInternal/tryGetService") + +-- selene: allow(incorrect_standard_library_use) +type InternalSyncService = typeof(game:GetService("InternalSyncService")) + +local InternalSyncService: InternalSyncService? = tryGetService("InternalSyncService") + +local function getInternalSyncItems(): { InternalSyncItem } + local internalSyncItems: { InternalSyncItem } = {} + + if InternalSyncService then + for _, child in InternalSyncService:GetChildren() do + if child:IsA("InternalSyncItem") then + table.insert(internalSyncItems, child) + end + end + end + + return internalSyncItems +end + +return getInternalSyncItems diff --git a/src/RobloxInternal/getMostLikelyProjectSources.luau b/src/RobloxInternal/getMostLikelyProjectSources.luau new file mode 100644 index 00000000..57a73f6c --- /dev/null +++ b/src/RobloxInternal/getMostLikelyProjectSources.luau @@ -0,0 +1,36 @@ +local Sift = require("@pkg/Sift") + +local getInternalSyncItems = require("@root/RobloxInternal/getInternalSyncItems") + +local PATTERNS = { + "src.*$", + "dist.*$", + "modules.*$", +} + +local function getMostLikelyProjectSources(): { Instance } + local internalSyncItems = getInternalSyncItems() + + if #internalSyncItems > 0 then + local sorted = Sift.List.sort(internalSyncItems, function(a: InternalSyncItem, b: InternalSyncItem) + for _, pattern in PATTERNS do + local aMatches = a.Path:match(pattern) + local bMatches = b.Path:match(pattern) + + if aMatches and not bMatches then + return true + elseif bMatches and not aMatches then + return false + end + end + return nil + end) + + return Sift.List.map(sorted, function(internalSyncItem) + return internalSyncItem.Target + end) + end + return {} +end + +return getMostLikelyProjectSources diff --git a/src/RobloxInternal/tryGetService.luau b/src/RobloxInternal/tryGetService.luau new file mode 100644 index 00000000..3f0ed92e --- /dev/null +++ b/src/RobloxInternal/tryGetService.luau @@ -0,0 +1,17 @@ +local canAccess = require("@root/RobloxInternal/canAccess") + +local function tryGetService(serviceName: string): Instance? + local service: Instance? + + pcall(function() + service = game:GetService(serviceName) + end) + + if service and canAccess(service) then + return service + else + return nil + end +end + +return tryGetService diff --git a/src/Storybook/OnboardingTemplate/ComponentTemplate.luau b/src/Storybook/OnboardingTemplate/ComponentTemplate.luau new file mode 100644 index 00000000..c820ce8e --- /dev/null +++ b/src/Storybook/OnboardingTemplate/ComponentTemplate.luau @@ -0,0 +1,17 @@ +export type Props = { + nameToGreet: string, +} + +local function HelloWorld(props: Props) + local label = Instance.new("TextLabel") + label.Text = `Hello, {props.nameToGreet}!` + label.AutomaticSize = Enum.AutomaticSize.XY + label.BackgroundTransparency = 1 + label.TextColor3 = Color3.fromRGB(255, 255, 255) + label.TextSize = 24 + label.Font = Enum.Font.BuilderSansMedium + + return label +end + +return HelloWorld diff --git a/src/Storybook/OnboardingTemplate/StoryTemplate.luau b/src/Storybook/OnboardingTemplate/StoryTemplate.luau new file mode 100644 index 00000000..aa9a465e --- /dev/null +++ b/src/Storybook/OnboardingTemplate/StoryTemplate.luau @@ -0,0 +1,18 @@ +local HelloWorld = require(script.Parent.HelloWorld) + +local controls = { + nameToGreet = "World", +} + +type Props = { + controls: typeof(controls), +} + +return { + controls = controls, + story = function(props: Props) + return HelloWorld({ + nameToGreet = props.controls.nameToGreet, + }) + end, +} diff --git a/src/Storybook/OnboardingTemplate/StorybookTemplate.luau b/src/Storybook/OnboardingTemplate/StorybookTemplate.luau new file mode 100644 index 00000000..d42a9f59 --- /dev/null +++ b/src/Storybook/OnboardingTemplate/StorybookTemplate.luau @@ -0,0 +1,5 @@ +return { + storyRoots = { + script.Parent.Components, + }, +} diff --git a/src/Storybook/StoryCanvas.luau b/src/Storybook/StoryCanvas.luau index a1927253..f8888ae7 100644 --- a/src/Storybook/StoryCanvas.luau +++ b/src/Storybook/StoryCanvas.luau @@ -1,4 +1,3 @@ -local ModuleLoader = require("@pkg/ModuleLoader") local React = require("@pkg/React") local Storyteller = require("@pkg/Storyteller") @@ -7,12 +6,10 @@ local useTheme = require("@root/Common/useTheme") local e = React.createElement -type ModuleLoader = ModuleLoader.ModuleLoader type LoadedStorybook = Storyteller.LoadedStorybook type Props = { story: ModuleScript, - loader: ModuleLoader, storybook: LoadedStorybook, layoutOrder: number?, } @@ -43,7 +40,6 @@ local function Canvas(props: Props) }), StoryView = props.story and e(StoryView, { - loader = props.loader, story = props.story, storybook = props.storybook, }), diff --git a/src/Storybook/StoryControls.luau b/src/Storybook/StoryControls.luau index 213c0912..23f6eab4 100644 --- a/src/Storybook/StoryControls.luau +++ b/src/Storybook/StoryControls.luau @@ -9,8 +9,13 @@ local useTheme = require("@root/Common/useTheme") local useMemo = React.useMemo local e = React.createElement +local function isArray(obj: { [any]: any }): boolean + return #obj > 0 and next(obj, #obj) == nil +end + type Props = { - controls: { [string]: any }, + controlsSchema: { [string]: any }, + changedControls: { [string]: any }, setControl: (key: string, value: any) -> (), layoutOrder: number?, } @@ -21,7 +26,7 @@ local function StoryControls(props: Props) local sortedControls: { { name: string, value: any } } = useMemo(function() local result = {} - for _, entry in Sift.Dictionary.entries(props.controls) do + for _, entry in Sift.Dictionary.entries(props.controlsSchema) do table.insert(result, { name = entry[1], value = entry[2], @@ -31,7 +36,7 @@ local function StoryControls(props: Props) return Sift.List.sort(result, function(a, b) return a.name < b.name end) - end, { props.controls }) + end, { props.controlsSchema }) local controlElements: { [string]: React.Node } = {} for index, control in sortedControls do @@ -51,9 +56,10 @@ local function StoryControls(props: Props) initialState = control.value, onStateChange = setControl, }) - elseif controlType == "table" then + elseif controlType == "table" and isArray(control.value) then + local default = props.changedControls[control.name] option = React.createElement(Dropdown, { - default = control.value[1], + default = if default then default else control.value[1], options = control.value, onOptionChange = setControl, }) diff --git a/src/Storybook/StoryError.luau b/src/Storybook/StoryError.luau index 97878ad7..19596cd7 100644 --- a/src/Storybook/StoryError.luau +++ b/src/Storybook/StoryError.luau @@ -1,19 +1,113 @@ local React = require("@pkg/React") -local SelectableTextLabel = require("@root/Forms/SelectableTextLabel") +local Storyteller = require("@pkg/Storyteller") + +local CodeBlock = require("@root/Common/CodeBlock") +local ScrollingFrame = require("@root/Common/ScrollingFrame") +local nextLayoutOrder = require("@root/Common/nextLayoutOrder") local useTheme = require("@root/Common/useTheme") +type LoadedStory = Storyteller.LoadedStory + export type Props = { err: string, + storyModule: ModuleScript, layoutOrder: number?, } local function StoryError(props: Props) local theme = useTheme() - return React.createElement(SelectableTextLabel, { + local storySource = if props.storyModule then props.storyModule.Source else nil + + return React.createElement(ScrollingFrame, { + ScrollingDirection = Enum.ScrollingDirection.XY, LayoutOrder = props.layoutOrder, - Text = props.err, - TextColor3 = theme.alert, + }, { + Layout = React.createElement("UIListLayout", { + SortOrder = Enum.SortOrder.LayoutOrder, + FillDirection = Enum.FillDirection.Vertical, + Padding = theme.paddingLarge, + }), + + Padding = React.createElement("UIPadding", { + PaddingTop = theme.paddingLarge, + PaddingRight = theme.paddingLarge, + PaddingBottom = theme.paddingLarge, + PaddingLeft = theme.paddingLarge, + }), + + MainText = React.createElement("TextLabel", { + LayoutOrder = nextLayoutOrder(), + Text = `Failed to load {props.storyModule.Name}`, + AutomaticSize = Enum.AutomaticSize.XY, + BackgroundTransparency = 1, + Font = theme.font, + TextColor3 = theme.text, + TextSize = theme.textSize, + TextXAlignment = Enum.TextXAlignment.Left, + TextYAlignment = Enum.TextYAlignment.Center, + }), + + Problem = React.createElement("Frame", { + AutomaticSize = Enum.AutomaticSize.XY, + BackgroundTransparency = 1, + LayoutOrder = nextLayoutOrder(), + }, { + Layout = React.createElement("UIListLayout", { + SortOrder = Enum.SortOrder.LayoutOrder, + FillDirection = Enum.FillDirection.Vertical, + Padding = theme.padding, + }), + + Title = React.createElement("TextLabel", { + LayoutOrder = nextLayoutOrder(), + Text = "Error", + AutomaticSize = Enum.AutomaticSize.XY, + BackgroundTransparency = 1, + Font = theme.headerFont, + TextColor3 = theme.text, + TextSize = theme.headerTextSize, + TextXAlignment = Enum.TextXAlignment.Left, + TextYAlignment = Enum.TextYAlignment.Center, + }), + + CodeBlock = React.createElement(CodeBlock, { + source = props.err, + sourceColor = theme.alert, + layoutOrder = nextLayoutOrder(), + }), + }), + + StorySource = if storySource + then React.createElement("Frame", { + AutomaticSize = Enum.AutomaticSize.XY, + BackgroundTransparency = 1, + LayoutOrder = nextLayoutOrder(), + }, { + Layout = React.createElement("UIListLayout", { + SortOrder = Enum.SortOrder.LayoutOrder, + FillDirection = Enum.FillDirection.Vertical, + Padding = theme.padding, + }), + + Title = React.createElement("TextLabel", { + LayoutOrder = nextLayoutOrder(), + Text = "Story Source", + AutomaticSize = Enum.AutomaticSize.XY, + BackgroundTransparency = 1, + Font = theme.headerFont, + TextColor3 = theme.text, + TextSize = theme.headerTextSize, + TextXAlignment = Enum.TextXAlignment.Left, + TextYAlignment = Enum.TextYAlignment.Center, + }), + + CodeBlock = React.createElement(CodeBlock, { + source = storySource, + layoutOrder = nextLayoutOrder(), + }), + }) + else nil, }) end diff --git a/src/Storybook/StoryPreview.luau b/src/Storybook/StoryPreview.luau index 2e3c3cbd..db32a160 100644 --- a/src/Storybook/StoryPreview.luau +++ b/src/Storybook/StoryPreview.luau @@ -76,9 +76,10 @@ local StoryPreview = React.forwardRef(function(providedProps: Props, ref: any) end end, { props.story, props.isMountedInViewport } :: { unknown }) - if err then + if err and props.story then return e(StoryError, { layoutOrder = props.layoutOrder, + storyModule = props.story.source, err = err, }) else diff --git a/src/Storybook/StoryView.luau b/src/Storybook/StoryView.luau index 0f29defb..e7e74340 100644 --- a/src/Storybook/StoryView.luau +++ b/src/Storybook/StoryView.luau @@ -1,6 +1,5 @@ local Selection = game:GetService("Selection") -local ModuleLoader = require("@pkg/ModuleLoader") local React = require("@pkg/React") local Sift = require("@pkg/Sift") local Storyteller = require("@pkg/Storyteller") @@ -20,11 +19,9 @@ local useZoom = require("@root/Common/useZoom") local e = React.createElement -type ModuleLoader = ModuleLoader.ModuleLoader type LoadedStorybook = Storyteller.LoadedStorybook type Props = { - loader: ModuleLoader, story: ModuleScript, storybook: LoadedStorybook, } @@ -32,7 +29,7 @@ type Props = { local function StoryView(props: Props) local theme = useTheme() local settingsContext = SettingsContext.use() - local story, storyErr = Storyteller.useStory(props.story, props.storybook, props.loader) + local story, storyErr = Storyteller.useStory(props.story, props.storybook) local zoom = useZoom(props.story) local plugin = React.useContext(PluginContext.Context) local changedControls, setChangedControls = React.useState({}) @@ -45,23 +42,8 @@ local function StoryView(props: Props) setChangedControls({}) end, { story }) - local controlsWithUserOverrides = React.useMemo(function() - local controls = {} - if story and story.controls then - for key, value in story.controls do - local override = changedControls[key] - - if override ~= nil and typeof(value) ~= "table" then - controls[key] = override - else - controls[key] = value - end - end - end - return controls - end, { story, changedControls } :: { unknown }) - - local showControls = controlsWithUserOverrides and not Sift.isEmpty(controlsWithUserOverrides) + local controlsSchema = if story then story.controls else nil + local showControls = controlsSchema and not Sift.isEmpty(controlsSchema) local setControl = React.useCallback(function(control: string, newValue: any) setChangedControls(function(prev) @@ -100,11 +82,16 @@ local function StoryView(props: Props) setTopbarHeight(rbx.AbsoluteSize.Y) end, {}) + React.useEffect(function() + setChangedControls({}) + end, { story }) + return e("Frame", { Size = UDim2.fromScale(1, 1), BackgroundTransparency = 1, }, { Error = storyErr and e(StoryError, { + storyModule = props.story, err = storyErr, }), @@ -172,7 +159,7 @@ local function StoryView(props: Props) StoryPreview = e(StoryPreview, { zoom = zoom.value, story = story, - controls = Sift.Dictionary.merge(controlsWithUserOverrides, changedControls), + controls = Sift.Dictionary.merge(controlsSchema, changedControls), storyModule = props.story, isMountedInViewport = isMountedInViewport, ref = storyParentRef, @@ -195,7 +182,8 @@ local function StoryView(props: Props) BackgroundColor3 = theme.sidebar, }, { StoryControls = e(StoryControls, { - controls = controlsWithUserOverrides, + controlsSchema = controlsSchema, + changedControls = changedControls, setControl = setControl, }), }), diff --git a/src/Storybook/StorybookError.luau b/src/Storybook/StorybookError.luau new file mode 100644 index 00000000..c2ea1b6e --- /dev/null +++ b/src/Storybook/StorybookError.luau @@ -0,0 +1,111 @@ +local React = require("@pkg/React") +local Storyteller = require("@pkg/Storyteller") + +local CodeBlock = require("@root/Common/CodeBlock") +local ScrollingFrame = require("@root/Common/ScrollingFrame") +local nextLayoutOrder = require("@root/Common/nextLayoutOrder") +local useTheme = require("@root/Common/useTheme") + +type UnavailableStorybook = Storyteller.UnavailableStorybook + +export type Props = { + unavailableStorybook: UnavailableStorybook, + layoutOrder: number?, +} + +local function StorybookError(props: Props) + local theme = useTheme() + + local storybookSource = props.unavailableStorybook.storybook.source.Source + + return React.createElement(ScrollingFrame, { + ScrollingDirection = Enum.ScrollingDirection.XY, + LayoutOrder = props.layoutOrder, + }, { + Layout = React.createElement("UIListLayout", { + SortOrder = Enum.SortOrder.LayoutOrder, + FillDirection = Enum.FillDirection.Vertical, + Padding = theme.paddingLarge, + }), + + Padding = React.createElement("UIPadding", { + PaddingTop = theme.paddingLarge, + PaddingRight = theme.paddingLarge, + PaddingBottom = theme.paddingLarge, + PaddingLeft = theme.paddingLarge, + }), + + MainText = React.createElement("TextLabel", { + LayoutOrder = nextLayoutOrder(), + Text = `Failed to load {props.unavailableStorybook.storybook.name}`, + AutomaticSize = Enum.AutomaticSize.XY, + BackgroundTransparency = 1, + Font = theme.font, + TextColor3 = theme.text, + TextSize = theme.textSize, + TextXAlignment = Enum.TextXAlignment.Left, + TextYAlignment = Enum.TextYAlignment.Center, + }), + + Problem = React.createElement("Frame", { + AutomaticSize = Enum.AutomaticSize.XY, + BackgroundTransparency = 1, + LayoutOrder = nextLayoutOrder(), + }, { + Layout = React.createElement("UIListLayout", { + SortOrder = Enum.SortOrder.LayoutOrder, + FillDirection = Enum.FillDirection.Vertical, + Padding = theme.padding, + }), + + Title = React.createElement("TextLabel", { + LayoutOrder = nextLayoutOrder(), + Text = "Error", + AutomaticSize = Enum.AutomaticSize.XY, + BackgroundTransparency = 1, + Font = theme.headerFont, + TextColor3 = theme.text, + TextSize = theme.headerTextSize, + TextXAlignment = Enum.TextXAlignment.Left, + TextYAlignment = Enum.TextYAlignment.Center, + }), + + CodeBlock = React.createElement(CodeBlock, { + source = props.unavailableStorybook.problem, + sourceColor = theme.alert, + layoutOrder = nextLayoutOrder(), + }), + }), + + StorybookSource = React.createElement("Frame", { + AutomaticSize = Enum.AutomaticSize.XY, + BackgroundTransparency = 1, + LayoutOrder = nextLayoutOrder(), + }, { + Layout = React.createElement("UIListLayout", { + SortOrder = Enum.SortOrder.LayoutOrder, + FillDirection = Enum.FillDirection.Vertical, + Padding = theme.padding, + }), + + Title = React.createElement("TextLabel", { + LayoutOrder = nextLayoutOrder(), + Text = "Storybook Source", + AutomaticSize = Enum.AutomaticSize.XY, + BackgroundTransparency = 1, + Font = theme.headerFont, + TextColor3 = theme.text, + TextSize = theme.headerTextSize, + TextXAlignment = Enum.TextXAlignment.Left, + TextYAlignment = Enum.TextYAlignment.Center, + }), + + CodeBlock = React.createElement(CodeBlock, { + source = storybookSource, + layoutOrder = nextLayoutOrder(), + }), + }), + }) +end + +return StorybookError diff --git a/src/Storybook/StorybookError.story.luau b/src/Storybook/StorybookError.story.luau new file mode 100644 index 00000000..e1361fd7 --- /dev/null +++ b/src/Storybook/StorybookError.story.luau @@ -0,0 +1,32 @@ +local React = require("@pkg/React") +local Storyteller = require("@pkg/Storyteller") + +local ContextProviders = require("@root/Common/ContextProviders") +local MockPlugin = require("@root/Testing/MockPlugin") +local StorybookError = require("@root/Storybook/StorybookError") + +type UnavailableStorybook = Storyteller.UnavailableStorybook + +return { + summary = "Component for displaying error messages to the user", + story = function() + + local storybookModule = script.Parent.Parent["init.storybook"] + local unavailableStorybook: UnavailableStorybook = { + problem = "Something went wrong!", + storybook = { + name = storybookModule.Name, + source = storybookModule, + loader = {} :: any, + } + } + + return React.createElement(ContextProviders, { + plugin = MockPlugin.new() :: , + }, { + StorybookError = React.createElement(StorybookError, { + unavailableStorybook =unavailableStorybook + }), + }) + end, +} diff --git a/src/Storybook/StorybookTreeView.luau b/src/Storybook/StorybookTreeView.luau index 13a3c4e5..de85104f 100644 --- a/src/Storybook/StorybookTreeView.luau +++ b/src/Storybook/StorybookTreeView.luau @@ -1,21 +1,29 @@ +local HttpService = game:GetService("HttpService") + local React = require("@pkg/React") local Storyteller = require("@pkg/Storyteller") local TreeView = require("@root/TreeView") local createTreeNodesForStorybook = require("@root/Storybook/createTreeNodesForStorybook") local useLastOpenedStory = require("@root/Storybook/useLastOpenedStory") +local usePinnedInstances = require("@root/TreeView/usePinnedInstances") local usePrevious = require("@root/Common/usePrevious") type TreeNode = TreeView.TreeNode type LoadedStorybook = Storyteller.LoadedStorybook +type UnavailableStorybook = Storyteller.UnavailableStorybook local useEffect = React.useEffect local useRef = React.useRef export type Props = { searchTerm: string?, - storybooks: { LoadedStorybook }, + storybooks: { + avialable: { LoadedStorybook }, + unavailable: { UnavailableStorybook }, + }, onStoryChanged: ((storyModule: ModuleScript?, storybook: LoadedStorybook?) -> ())?, + onShowErrorPage: ((unavailableStorybook: UnavailableStorybook) -> ())?, layoutOrder: number?, } @@ -25,22 +33,81 @@ local function StorybookTreeView(props: Props) local selectedNode = treeViewContext.getSelectedNode() local prevSelectedNode = usePrevious(selectedNode) local storybookByNodeId = useRef({} :: { [string]: LoadedStorybook }) + local unavailableStorybookByNodeId = useRef({} :: { [string]: UnavailableStorybook }) local lastOpenedStory, setLastOpenedStory = useLastOpenedStory() + local pinning = usePinnedInstances() useEffect(function() - storybookByNodeId.current = {} local roots: { TreeNode } = {} - for _, storybook in props.storybooks do + + local pinnedInstances = pinning.getPinnedInstances() + if #pinnedInstances > 0 then + local pins: TreeNode = { + id = HttpService:GenerateGUID(), + label = "Starred", + icon = "star", + isExpanded = false, + children = {}, + } + + for _, pinnedInstance in pinnedInstances do + local node: { TreeNode } + if pinnedInstance.instance then + node = treeViewContext.getNodeByInstance(pinnedInstance.instance) + end + + if not node then + node = { + id = HttpService:GenerateGUID(), + label = `ERR: {pinnedInstance.path}`, + icon = "folder", -- TODO: Use an error icon + isExpanded = true, + children = {}, + } + end + + table.insert(pins.children, node) + end + + table.insert(roots, pins) + end + + for _, storybook in props.storybooks.available do local root = createTreeNodesForStorybook(storybook) table.insert(roots, root) storybookByNodeId.current[root.id] = storybook end + + if #props.storybooks.unavailable > 0 then + local unavailableStorybooks: TreeNode = { + id = HttpService:GenerateGUID(), + label = "Unavailable Storybooks", + icon = "folder", + isExpanded = false, + children = {}, + } + + for _, unavailableStorybook in props.storybooks.unavailable do + local root = { + id = HttpService:GenerateGUID(), + label = unavailableStorybook.storybook.name, + icon = "alert", + isExpanded = false, + children = {}, + } + table.insert(unavailableStorybooks.children, root) + unavailableStorybookByNodeId.current[root.id] = unavailableStorybook + end + + table.insert(roots, unavailableStorybooks) + end + treeViewContext.setRoots(roots) return function() treeViewContext.setRoots({}) end - end, { props.storybooks, treeViewContext.setRoots } :: { unknown }) + end, { props.storybooks.available, props.storybooks.unavailable, treeViewContext.setRoots } :: { unknown }) useEffect(function() treeViewContext.search(props.searchTerm) @@ -62,28 +129,43 @@ local function StorybookTreeView(props: Props) end end, { lastOpenedStory, treeViewContext.getNodeByInstance, treeViewContext.activateNode } :: { unknown }) - useEffect(function() - if props.onStoryChanged and selectedNode ~= prevSelectedNode then - if selectedNode then - if - selectedNode.icon == TreeView.TreeNodeIcon.Story - and selectedNode.instance - and selectedNode.instance:IsA("ModuleScript") - then - local ancestry = TreeView.getAncestry(selectedNode) - local root = ancestry[#ancestry] - local storybook = storybookByNodeId.current[root.id] - - if storybook then - props.onStoryChanged(selectedNode.instance, storybook) - setLastOpenedStory(selectedNode.instance) + useEffect( + function() + if selectedNode ~= prevSelectedNode then + if props.onStoryChanged then + if selectedNode then + if + selectedNode.icon == TreeView.TreeNodeIcon.Story + and selectedNode.instance + and selectedNode.instance:IsA("ModuleScript") + then + local ancestry = TreeView.getAncestry(selectedNode) + local root = ancestry[#ancestry] + local storybook = storybookByNodeId.current[root.id] + + if storybook then + props.onStoryChanged(selectedNode.instance, storybook) + setLastOpenedStory(selectedNode.instance) + end + end + else + props.onStoryChanged(nil, nil) + setLastOpenedStory(nil) + end + end + + if props.onShowErrorPage then + if selectedNode and selectedNode.icon == TreeView.TreeNodeIcon.Alert then + local unavailableStorybook = unavailableStorybookByNodeId.current[selectedNode.id] + if unavailableStorybook then + props.onShowErrorPage(unavailableStorybook) + end end end - else - props.onStoryChanged(nil, nil) end - end - end, { props.onStoryChanged, selectedNode, prevSelectedNode, setLastOpenedStory } :: { unknown }) + end, + { props.onShowErrorPage, props.onStoryChanged, selectedNode, prevSelectedNode, setLastOpenedStory } :: { unknown } + ) return React.createElement(TreeView.TreeView, { layoutOrder = props.layoutOrder, diff --git a/src/Storybook/StorybookTreeView.story.luau b/src/Storybook/StorybookTreeView.story.luau index fc31a4d3..6903f111 100644 --- a/src/Storybook/StorybookTreeView.story.luau +++ b/src/Storybook/StorybookTreeView.story.luau @@ -1,4 +1,3 @@ -local ModuleLoader = require("@pkg/ModuleLoader") local React = require("@pkg/React") local Storyteller = require("@pkg/Storyteller") @@ -6,10 +5,8 @@ local ContextProviders = require("@root/Common/ContextProviders") local MockPlugin = require("@root/Testing/MockPlugin") local StorybookTreeView = require("./StorybookTreeView") -local loader = ModuleLoader.new() - local function Story() - local storybooks = Storyteller.useStorybooks(game, loader) + local storybooks = Storyteller.useStorybooks(game) return React.createElement("Frame", { Size = UDim2.fromOffset(300, 0), diff --git a/src/Storybook/createOnboardingStorybook.luau b/src/Storybook/createOnboardingStorybook.luau new file mode 100644 index 00000000..fea9989f --- /dev/null +++ b/src/Storybook/createOnboardingStorybook.luau @@ -0,0 +1,27 @@ +local STORYBOOK_TEMPLATE = script.Parent.OnboardingTemplate["StorybookTemplate"] +local STORY_TEMPLATE = script.Parent.OnboardingTemplate["StoryTemplate"] +local COMPONENT_TEMPLATE = script.Parent.OnboardingTemplate["ComponentTemplate"] + +local function createOnboardingStorybook(storybookName: string, parent: Instance) + local components = parent:FindFirstChild("Components") + if not components then + components = Instance.new("Folder") + components.Name = "Components" + end + + local component = COMPONENT_TEMPLATE:Clone() + component.Name = "HelloWorld" + component.Parent = components + + local story = STORY_TEMPLATE:Clone() + story.Name = "HelloWorld.story" + story.Parent = components + + local storybookModule = STORYBOOK_TEMPLATE:Clone() + storybookModule.Name = `{storybookName}.storybook` + + components.Parent = parent + storybookModule.Parent = parent +end + +return createOnboardingStorybook diff --git a/src/Storybook/createTreeNodesForStorybook.luau b/src/Storybook/createTreeNodesForStorybook.luau index ed061634..49968f23 100644 --- a/src/Storybook/createTreeNodesForStorybook.luau +++ b/src/Storybook/createTreeNodesForStorybook.luau @@ -46,6 +46,7 @@ local function createTreeNodesForStorybook(storybook: LoadedStorybook): TreeNode local parentNode: TreeNode = { id = HttpService:GenerateGUID(), label = parentInstance.Name, + instance = parentInstance, icon = "folder", isExpanded = false, children = { currentNode }, diff --git a/src/Storybook/useLastOpenedStory.luau b/src/Storybook/useLastOpenedStory.luau index bd9e1caa..8165ca0f 100644 --- a/src/Storybook/useLastOpenedStory.luau +++ b/src/Storybook/useLastOpenedStory.luau @@ -1,32 +1,35 @@ local React = require("@pkg/React") -local PluginContext = require("@root/Plugin/PluginContext") +local LocalStorageContext = require("@root/Plugin/LocalStorageContext") local SettingsContext = require("@root/UserSettings/SettingsContext") -local getInstanceFromFullName = require("@root/Common/getInstanceFromFullName") +local getInstanceFromPath = require("@root/Common/getInstanceFromPath") +local getInstancePath = require("@root/Common/getInstancePath") -local useContext = React.useContext local useCallback = React.useCallback local useMemo = React.useMemo -local function useLastOpenedStory(): (ModuleScript?, (storyModule: ModuleScript?) -> ()) - local plugin = useContext(PluginContext.Context) +local REMEMBER_LAST_OPENED_STORY_KEY = "rememberLastOpenedStory" +local LAST_OPENED_STORY_PATH_KEY = "lastOpenedStoryPath" +local function useLastOpenedStory(): (ModuleScript?, (storyModule: ModuleScript?) -> ()) + local localStorage = LocalStorageContext.use() local settingsContext = SettingsContext.use() - local rememberLastOpenedStory = settingsContext.getSetting("rememberLastOpenedStory") local setLastOpenedStory = useCallback(function(storyModule: ModuleScript?) - plugin:SetSetting("lastOpenedStoryPath", if storyModule then storyModule:GetFullName() else nil) - end, { plugin }) + localStorage.set(LAST_OPENED_STORY_PATH_KEY, if storyModule then getInstancePath(storyModule) else nil) + end, { localStorage.set }) local lastOpenedStory = useMemo(function(): ModuleScript? + local rememberLastOpenedStory = settingsContext.getSetting(REMEMBER_LAST_OPENED_STORY_KEY) + if not rememberLastOpenedStory then return nil end - local lastOpenedStoryPath = plugin:GetSetting("lastOpenedStoryPath") + local lastOpenedStoryPath = localStorage.get(LAST_OPENED_STORY_PATH_KEY) - if lastOpenedStoryPath then - local instance = getInstanceFromFullName(lastOpenedStoryPath) + if lastOpenedStoryPath and typeof(lastOpenedStoryPath) == "string" then + local instance = getInstanceFromPath(lastOpenedStoryPath) if instance and instance:IsA("ModuleScript") then return instance @@ -34,7 +37,7 @@ local function useLastOpenedStory(): (ModuleScript?, (storyModule: ModuleScript? end return nil - end, { rememberLastOpenedStory, plugin }) + end, { settingsContext, localStorage.get }) return lastOpenedStory, setLastOpenedStory end diff --git a/src/TreeView/TreeNode.luau b/src/TreeView/TreeNode.luau index 0842f63f..f6cb4ba2 100644 --- a/src/TreeView/TreeNode.luau +++ b/src/TreeView/TreeNode.luau @@ -6,6 +6,7 @@ local TreeViewContext = require("@root/TreeView/TreeViewContext") local assets = require("@root/assets") local constants = require("@root/constants") local types = require("@root/TreeView/types") +local usePinnedInstances = require("@root/TreeView/usePinnedInstances") local useTheme = require("@root/Common/useTheme") local useTreeNodeIcon = require("@root/TreeView/useTreeNodeIcon") @@ -39,6 +40,7 @@ local function TreeNode(props: Props) local treeViewContext = TreeViewContext.use() local isExpanded = treeViewContext.isExpanded(props.node) local isSelected = treeViewContext.isSelected(props.node) + local pinning = usePinnedInstances() local styles = useSpring({ hover = if isHovered or isSelected then 0 else 1, @@ -79,6 +81,12 @@ local function TreeNode(props: Props) treeViewContext.activateNode(props.node) end, { props.onActivated, treeViewContext, props.node } :: { unknown }) + local onTogglePin = useCallback(function() + if props.node.instance then + pinning.togglePin(props.node.instance) + end + end, { pinning, props.node }) + local backgroundColor = useMemo(function(): Color3? if isSelected then return theme.selection @@ -150,9 +158,24 @@ local function TreeNode(props: Props) }), }), + Pin = if props.node.instance and (props.node.icon == "story" or props.node.icon == "storybook") + then React.createElement("ImageButton", { + LayoutOrder = 3, + BackgroundTransparency = 1, + AutomaticSize = Enum.AutomaticSize.XY, + [React.Event.Activated] = onTogglePin, + }, { + Icon = React.createElement(Sprite, { + image = if pinning.isPinned(props.node.instance) then assets.StarFilled else assets.Star, + color = theme.text, + size = UDim2.fromOffset(16, 16), + }), + }) + else nil, + Toggle = if #props.node.children > 0 then React.createElement("Frame", { - LayoutOrder = 3, + LayoutOrder = 4, BackgroundTransparency = 1, AutomaticSize = Enum.AutomaticSize.XY, }, { diff --git a/src/TreeView/TreeViewContext.luau b/src/TreeView/TreeViewContext.luau index e89b824b..93d18f45 100644 --- a/src/TreeView/TreeViewContext.luau +++ b/src/TreeView/TreeViewContext.luau @@ -110,7 +110,7 @@ local function TreeNodeProvider(props: { local getNodeByInstance = useCallback(function(instance: Instance) return nodes.byInstance[instance] - end, { nodes.byId }) + end, { nodes.byInstance }) local getSelectedNode = useCallback(function() return selectedNode diff --git a/src/TreeView/types.luau b/src/TreeView/types.luau index f84293fc..460db899 100644 --- a/src/TreeView/types.luau +++ b/src/TreeView/types.luau @@ -1,12 +1,14 @@ local types = {} -export type TreeNodeIcon = "none" | "story" | "storybook" | "folder" +export type TreeNodeIcon = "none" | "story" | "storybook" | "folder" | "star" | "alert" types.TreeNodeIcon = { None = "none" :: "none", Story = "story" :: "story", Storybook = "storybook" :: "storybook", Folder = "folder" :: "folder", + Star = "star" :: "star", + Alert = "alert" :: "alert", } export type PartialTreeNode = { diff --git a/src/TreeView/usePinnedInstances.luau b/src/TreeView/usePinnedInstances.luau new file mode 100644 index 00000000..6644329d --- /dev/null +++ b/src/TreeView/usePinnedInstances.luau @@ -0,0 +1,82 @@ +local React = require("@pkg/React") +local Sift = require("@pkg/Sift") + +local LocalStorageContext = require("@root/Plugin/LocalStorageContext") +local getInstanceFromPath = require("@root/Common/getInstanceFromPath") +local getInstancePath = require("@root/Common/getInstancePath") +local usePrevious = require("@root/Common/usePrevious") + +local useCallback = React.useCallback +local useState = React.useState +local useEffect = React.useEffect + +local PINNED_INSTANCES_KEY = "pinnedInstancePaths" + +export type PinnedInstance = { + path: string, + instance: Instance?, +} + +local function usePinnedInstances(): (ModuleScript?, (storyModule: ModuleScript?) -> ()) + local localStorage = LocalStorageContext.use() + + local pinnedPaths, setPinnedPaths = useState(function() + return localStorage.get(PINNED_INSTANCES_KEY) or {} + end) + local prevPinnedPaths = usePrevious(pinnedPaths) + + local pin = useCallback(function(instance: Instance) + setPinnedPaths(function(prev) + return Sift.List.append(prev, getInstancePath(instance)) + end) + end, {}) + + local unpin = useCallback(function(instance: Instance) + setPinnedPaths(function(prev) + return Sift.List.filter(prev, function(pinnedPath) + return pinnedPath ~= getInstancePath(instance) + end) + end) + end, {}) + + local getPinnedInstances = useCallback(function(): { PinnedInstance } + local pinnedInstances: { PinnedInstance } = {} + + for _, pinnedPath in pinnedPaths do + table.insert(pinnedInstances, { + path = pinnedPath, + instance = getInstanceFromPath(pinnedPath), + }) + end + + return pinnedInstances + end, { pinnedPaths }) + + local isPinned = useCallback(function(instance: Instance) + return table.find(pinnedPaths, instance:GetFullName()) ~= nil + end, { pinnedPaths }) + + local togglePin = useCallback(function(instance: Instance) + if isPinned(instance) then + unpin(instance) + else + pin(instance) + end + end, { isPinned, unpin, pin }) + + useEffect(function() + if prevPinnedPaths and pinnedPaths ~= prevPinnedPaths then + localStorage.set(PINNED_INSTANCES_KEY, pinnedPaths) + end + end, { localStorage.set, pinnedPaths, prevPinnedPaths }) + + return { + pin = pin, + unpin = unpin, + isPinned = isPinned, + togglePin = togglePin, + getPinnedInstances = getPinnedInstances, + } +end + +return usePinnedInstances diff --git a/src/TreeView/useTreeNodeIcon.luau b/src/TreeView/useTreeNodeIcon.luau index 92525320..487ce428 100644 --- a/src/TreeView/useTreeNodeIcon.luau +++ b/src/TreeView/useTreeNodeIcon.luau @@ -1,6 +1,6 @@ local assets = require("@root/assets") -local useTheme = require("@root/Common/useTheme") local types = require("./types") +local useTheme = require("@root/Common/useTheme") type TreeNodeIcon = types.TreeNodeIcon @@ -19,6 +19,10 @@ local function useTreeNodeIcon(icon: TreeNodeIcon): (Sprite, Color3) return assets.Storybook, theme.textFaded elseif icon == types.TreeNodeIcon.Folder then return assets.Folder, theme.directory + elseif icon == types.TreeNodeIcon.Star then + return assets.Star, theme.star + elseif icon == types.TreeNodeIcon.Alert then + return assets.Alert, theme.alert else return assets.Folder, theme.textFaded end diff --git a/src/assets.luau b/src/assets.luau index d00fe303..10c4e9ea 100644 --- a/src/assets.luau +++ b/src/assets.luau @@ -1,53 +1,68 @@ -- This file was @generated by Tarmac. It is not intended for manual editing. return { + Alert = { + Image = "rbxassetid://74272179538762", + ImageRectOffset = Vector2.new(0, 308), + ImageRectSize = Vector2.new(24, 24), + }, ChevronRight = { - Image = "rbxassetid://18940815650", + Image = "rbxassetid://74272179538762", ImageRectOffset = Vector2.new(49, 226), ImageRectSize = Vector2.new(32, 32), }, Component = { - Image = "rbxassetid://18940815650", + Image = "rbxassetid://74272179538762", ImageRectOffset = Vector2.new(0, 275), ImageRectSize = Vector2.new(32, 32), }, Folder = { - Image = "rbxassetid://18940815650", + Image = "rbxassetid://74272179538762", ImageRectOffset = Vector2.new(345, 0), ImageRectSize = Vector2.new(32, 32), }, GitHubMark = { - Image = "rbxassetid://18940815650", + Image = "rbxassetid://74272179538762", ImageRectOffset = Vector2.new(0, 0), ImageRectSize = Vector2.new(230, 225), }, IconLight = { - Image = "rbxassetid://18940815650", + Image = "rbxassetid://74272179538762", ImageRectOffset = Vector2.new(231, 65), ImageRectSize = Vector2.new(42, 42), }, Magnify = { - Image = "rbxassetid://18940815650", + Image = "rbxassetid://74272179538762", ImageRectOffset = Vector2.new(0, 226), ImageRectSize = Vector2.new(48, 48), }, Minify = { - Image = "rbxassetid://18940815650", + Image = "rbxassetid://74272179538762", ImageRectOffset = Vector2.new(296, 0), ImageRectSize = Vector2.new(48, 48), }, Search = { - Image = "rbxassetid://18940815650", + Image = "rbxassetid://74272179538762", ImageRectOffset = Vector2.new(296, 49), ImageRectSize = Vector2.new(32, 32), }, + Star = { + Image = "rbxassetid://74272179538762", + ImageRectOffset = Vector2.new(82, 226), + ImageRectSize = Vector2.new(25, 24), + }, + StarFilled = { + Image = "rbxassetid://74272179538762", + ImageRectOffset = Vector2.new(49, 259), + ImageRectSize = Vector2.new(25, 24), + }, Storybook = { - Image = "rbxassetid://18940815650", + Image = "rbxassetid://74272179538762", ImageRectOffset = Vector2.new(231, 108), ImageRectSize = Vector2.new(32, 32), }, flipbook = { - Image = "rbxassetid://18940815650", + Image = "rbxassetid://74272179538762", ImageRectOffset = Vector2.new(231, 0), ImageRectSize = Vector2.new(64, 64), }, -} +} \ No newline at end of file diff --git a/src/init.server.luau b/src/init.server.luau index 3cbe3155..9e44fd69 100644 --- a/src/init.server.luau +++ b/src/init.server.luau @@ -4,49 +4,21 @@ if RunService:IsRunning() or not RunService:IsEdit() then return end -local ModuleLoader = require("@pkg/ModuleLoader") -local React = require("@pkg/React") -local ReactRoblox = require("@pkg/ReactRoblox") - -local ContextProviders = require("@root/Common/ContextProviders") -local PluginApp = require("@root/Plugin/PluginApp") -local createToggleButton = require("@root/Plugin/createToggleButton") -local createWidget = require("@root/Plugin/createWidget") +local createFlipbookPlugin = require("@root/Plugin/createFlipbookPlugin") local PLUGIN_NAME = "flipbook" local toolbar = plugin:CreateToolbar(PLUGIN_NAME) -local widget = createWidget(plugin, PLUGIN_NAME) -local root = ReactRoblox.createRoot(widget) -local disconnectButton = createToggleButton(toolbar, widget) - -local loader = ModuleLoader.new() - -local app = React.createElement(ContextProviders, { - plugin = plugin, -}, { - PluginApp = React.createElement(PluginApp, { - loader = loader, - }), -}) - -local widgetConn = widget:GetPropertyChangedSignal("Enabled"):Connect(function() - if widget.Enabled then - root:render(app) - else - root:unmount() - loader:clear() - end -end) - -if widget.Enabled then - root:render(app) -end -plugin.Unloading:Connect(function() - disconnectButton() - widgetConn:Disconnect() +local info = DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Top, true) + +local widget = plugin:CreateDockWidgetPluginGui(PLUGIN_NAME, info) +widget.Name = PLUGIN_NAME +widget.Title = PLUGIN_NAME +widget.ZIndexBehavior = Enum.ZIndexBehavior.Sibling + +local button = toolbar:CreateButton(PLUGIN_NAME, "Open story view", "rbxassetid://10277153751") + +local flipbookPlugin = createFlipbookPlugin(plugin, widget, button) - root:unmount() - loader:clear() -end) +plugin.Unloading:Connect(flipbookPlugin.unmount) diff --git a/src/stories.spec.luau b/src/stories.spec.luau index 0d26cc17..a8a0bcf7 100644 --- a/src/stories.spec.luau +++ b/src/stories.spec.luau @@ -1,7 +1,6 @@ local CoreGui = game:GetService("CoreGui") local JestGlobals = require("@pkg/JestGlobals") -local ModuleLoader = require("@pkg/ModuleLoader") local React = require("@pkg/React") local ReactRoblox = require("@pkg/ReactRoblox") local Sift = require("@pkg/Sift") @@ -31,21 +30,19 @@ describeEach({ -- FIXME: This is needed to get around a bug with React renders. I'm hoping -- to keep this for now, but in the future this should really be a -- ModuleLoader instance - local mockModuleLoader = ( - { - require = function(_self, path) - return (require :: any)(path) - end, - } :: any - ) :: ModuleLoader.ModuleLoader + local mockModuleLoader = { + require = function(_self, path) + return (require :: any)(path) + end, + } :: any - local storybook = Storyteller.loadStorybookModule(mockModuleLoader, storybookModule) + local storybook = Storyteller.loadStorybookModule(storybookModule, mockModuleLoader) describeEach({ Storyteller.findStoryModulesForStorybook(storybook), })("%s", function(storyModule) test("basic mount/unmount lifecycle", function() - local story = Storyteller.loadStoryModule(mockModuleLoader, storyModule, storybook) + local story = Storyteller.loadStoryModule(storyModule, storybook) if story.packages then story.packages = Sift.Dictionary.join(story.packages, { diff --git a/src/themes.luau b/src/themes.luau index a2b57a8d..db5a6325 100644 --- a/src/themes.luau +++ b/src/themes.luau @@ -22,6 +22,7 @@ export type Theme = { story: Color3, directory: Color3, alert: Color3, + star: Color3, github: Color3, @@ -54,6 +55,7 @@ local Light: Theme = { story = tailwind.green500, directory = tailwind.purple500, alert = tailwind.rose500, + star = tailwind.amber600, github = Color3.fromHex("#333333"), @@ -86,6 +88,7 @@ local Dark: Theme = { story = tailwind.green500, directory = tailwind.purple500, alert = tailwind.rose500, + star = tailwind.amber400, github = Color3.fromHex("#ffffff"), diff --git a/tarmac-manifest.toml b/tarmac-manifest.toml index 9dd6cda0..06d90870 100644 --- a/tarmac-manifest.toml +++ b/tarmac-manifest.toml @@ -1,59 +1,77 @@ +[inputs."img/Alert.png"] +hash = "e2f8c263a469a521d876de814fb5e3251084dbce9d41e06bf9037ccfe4710fd3" +id = 74272179538762 +slice = [[0, 308], [24, 332]] +packable = true + [inputs."img/ChevronRight.png"] hash = "4c91853652b4e0a6317804c54db6397ac0c710c819b282f7be2179e3abdc879c" -id = 18940815650 +id = 74272179538762 slice = [[49, 226], [81, 258]] packable = true [inputs."img/Component.png"] hash = "d51b19d5f35ac87fd8156500828f3e217b6fc420b70fee5d97b3577e4e6a8843" -id = 18940815650 +id = 74272179538762 slice = [[0, 275], [32, 307]] packable = true [inputs."img/Folder.png"] hash = "4c0e80a363d36a4d127d410795cb13eec74d55871b70174636c0c962c242835c" -id = 18940815650 +id = 74272179538762 slice = [[345, 0], [377, 32]] packable = true [inputs."img/GitHubMark.png"] hash = "a0fb973cd9bf0c1123dbe783f7a4093dbcf9b4910d6e385c16a837a04a02306c" -id = 18940815650 +id = 74272179538762 slice = [[0, 0], [230, 225]] packable = true [inputs."img/IconLight.png"] hash = "237c66813c2b5a58ae9c46166f72db8698fbb046922e2473ebaca8f7d7376ce1" -id = 18940815650 +id = 74272179538762 slice = [[231, 65], [273, 107]] packable = true [inputs."img/Magnify.png"] hash = "351db4b6132a9e02d01bc4cdc3c850099dce358673daaa0da66745b675ad38b1" -id = 18940815650 +id = 74272179538762 slice = [[0, 226], [48, 274]] packable = true [inputs."img/Minify.png"] hash = "694db8ce141475fc0f42980d2a6dfd058dfa155348a579fab29ca6dd5684ec14" -id = 18940815650 +id = 74272179538762 slice = [[296, 0], [344, 48]] packable = true [inputs."img/Search.png"] hash = "0ab89c0309f577f3ccae0e03b45292f6e2bca51cc9b8250f56ad756e04231c57" -id = 18940815650 +id = 74272179538762 slice = [[296, 49], [328, 81]] packable = true +[inputs."img/Star.png"] +hash = "ae3c74c88729a8600531b2ade6cbaaa091033fffd0de5145b8462d3a15d0a510" +id = 74272179538762 +slice = [[82, 226], [107, 250]] +packable = true + +[inputs."img/StarFilled.png"] +hash = "d958666976bb5a1ab5672e5a79d393b28fca26fb348f9b07ceecbcaf059e78f3" +id = 74272179538762 +slice = [[49, 259], [74, 283]] +packable = true + [inputs."img/Storybook.png"] hash = "2a4b8bd513a8fa7eebae482943ab3435a659359bd94b24eff1c3dcc6f1244799" -id = 18940815650 +id = 74272179538762 slice = [[231, 108], [263, 140]] packable = true [inputs."img/flipbook.png"] hash = "423e3dcff54fce8824f14d7cadcfb90eef2abecd817df2bee13951bd2674f79a" -id = 18940815650 +id = 74272179538762 slice = [[231, 0], [295, 64]] packable = true diff --git a/wally.toml b/wally.toml index f368d55e..a73e9495 100644 --- a/wally.toml +++ b/wally.toml @@ -7,15 +7,20 @@ realm = "shared" exclude = ["*"] [dependencies] -ModuleLoader = "flipbook-labs/module-loader@0.6.2" Storyteller = "flipbook-labs/storyteller@0.6.0" React = "jsdotlua/react@17.0.2" ReactRoblox = "jsdotlua/react-roblox@17.0.2" ReactSpring = "chriscerie/react-spring@2.0.0" Sift = "csqrl/sift@0.0.8" t = "osyrisrblx/t@3.0.0" +Highlighter = "boatbomber/highlighter@0.9.0" # dev dependencies Roact = "roblox/roact@1.4.4" Jest = "jsdotlua/jest@3.6.1-rc.2" JestGlobals = "jsdotlua/jest-globals@3.6.1-rc.2" + +# Storyteller dependencies +Prospector = "egomoose/prospector@1.1.0" +Janitor = "howmanysmall/janitor@1.13.15" +LuauPolyfill = "jsdotlua/luau-polyfill@1.2.7"